From 3a4992633ee62d5edfbb484d9c6bcb3cf158489d Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 31 Jul 2023 14:34:36 +0200 Subject: Migrate server to ESM Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports) --- packages/core-utils/package.json | 19 + .../src/abuse/abuse-predefined-reasons.ts | 14 + packages/core-utils/src/abuse/index.ts | 1 + packages/core-utils/src/common/array.ts | 41 + packages/core-utils/src/common/date.ts | 114 ++ packages/core-utils/src/common/index.ts | 10 + packages/core-utils/src/common/number.ts | 13 + packages/core-utils/src/common/object.ts | 86 ++ packages/core-utils/src/common/promises.ts | 58 + packages/core-utils/src/common/random.ts | 8 + packages/core-utils/src/common/regexp.ts | 5 + packages/core-utils/src/common/time.ts | 7 + packages/core-utils/src/common/url.ts | 150 +++ packages/core-utils/src/common/version.ts | 11 + packages/core-utils/src/i18n/i18n.ts | 119 ++ packages/core-utils/src/i18n/index.ts | 1 + packages/core-utils/src/index.ts | 7 + packages/core-utils/src/plugins/hooks.ts | 60 + packages/core-utils/src/plugins/index.ts | 1 + packages/core-utils/src/renderer/html.ts | 71 ++ packages/core-utils/src/renderer/index.ts | 2 + packages/core-utils/src/renderer/markdown.ts | 24 + packages/core-utils/src/users/index.ts | 1 + packages/core-utils/src/users/user-role.ts | 37 + packages/core-utils/src/videos/bitrate.ts | 113 ++ packages/core-utils/src/videos/common.ts | 24 + packages/core-utils/src/videos/index.ts | 2 + packages/core-utils/tsconfig.json | 11 + packages/ffmpeg/package.json | 19 + packages/ffmpeg/src/ffmpeg-command-wrapper.ts | 246 ++++ .../src/ffmpeg-default-transcoding-profile.ts | 187 +++ packages/ffmpeg/src/ffmpeg-edition.ts | 239 ++++ packages/ffmpeg/src/ffmpeg-images.ts | 92 ++ packages/ffmpeg/src/ffmpeg-live.ts | 184 +++ packages/ffmpeg/src/ffmpeg-utils.ts | 17 + packages/ffmpeg/src/ffmpeg-version.ts | 24 + packages/ffmpeg/src/ffmpeg-vod.ts | 256 +++++ packages/ffmpeg/src/ffprobe.ts | 184 +++ packages/ffmpeg/src/index.ts | 9 + packages/ffmpeg/src/shared/encoder-options.ts | 39 + packages/ffmpeg/src/shared/index.ts | 2 + packages/ffmpeg/src/shared/presets.ts | 93 ++ packages/ffmpeg/tsconfig.json | 12 + packages/models/package.json | 19 + packages/models/src/activitypub/activity.ts | 135 +++ .../models/src/activitypub/activitypub-actor.ts | 34 + .../src/activitypub/activitypub-collection.ts | 9 + .../activitypub/activitypub-ordered-collection.ts | 10 + .../models/src/activitypub/activitypub-root.ts | 5 + .../src/activitypub/activitypub-signature.ts | 6 + packages/models/src/activitypub/context.ts | 16 + packages/models/src/activitypub/index.ts | 9 + .../models/src/activitypub/objects/abuse-object.ts | 15 + .../src/activitypub/objects/activitypub-object.ts | 17 + .../src/activitypub/objects/cache-file-object.ts | 9 + .../src/activitypub/objects/common-objects.ts | 130 +++ packages/models/src/activitypub/objects/index.ts | 9 + .../activitypub/objects/playlist-element-object.ts | 10 + .../src/activitypub/objects/playlist-object.ts | 29 + .../activitypub/objects/video-comment-object.ts | 16 + .../models/src/activitypub/objects/video-object.ts | 74 ++ .../src/activitypub/objects/watch-action-object.ts | 22 + packages/models/src/activitypub/webfinger.ts | 9 + packages/models/src/actors/account.model.ts | 22 + packages/models/src/actors/actor-image.model.ts | 9 + packages/models/src/actors/actor-image.type.ts | 6 + packages/models/src/actors/actor.model.ts | 13 + packages/models/src/actors/custom-page.model.ts | 3 + packages/models/src/actors/follow.model.ts | 13 + packages/models/src/actors/index.ts | 6 + .../src/bulk/bulk-remove-comments-of-body.model.ts | 4 + packages/models/src/bulk/index.ts | 1 + packages/models/src/common/index.ts | 1 + packages/models/src/common/result-list.model.ts | 8 + .../src/custom-markup/custom-markup-data.model.ts | 58 + packages/models/src/custom-markup/index.ts | 1 + packages/models/src/feeds/feed-format.enum.ts | 7 + packages/models/src/feeds/index.ts | 1 + packages/models/src/http/http-methods.ts | 23 + packages/models/src/http/http-status-codes.ts | 366 ++++++ packages/models/src/http/index.ts | 2 + packages/models/src/index.ts | 20 + packages/models/src/joinpeertube/index.ts | 1 + packages/models/src/joinpeertube/versions.model.ts | 5 + packages/models/src/metrics/index.ts | 1 + .../src/metrics/playback-metric-create.model.ts | 22 + .../src/moderation/abuse/abuse-create.model.ts | 21 + .../src/moderation/abuse/abuse-filter.type.ts | 1 + .../src/moderation/abuse/abuse-message.model.ts | 10 + .../src/moderation/abuse/abuse-reason.model.ts | 22 + .../src/moderation/abuse/abuse-state.model.ts | 7 + .../src/moderation/abuse/abuse-update.model.ts | 7 + .../src/moderation/abuse/abuse-video-is.type.ts | 1 + .../models/src/moderation/abuse/abuse.model.ts | 70 ++ packages/models/src/moderation/abuse/index.ts | 8 + .../models/src/moderation/account-block.model.ts | 7 + .../models/src/moderation/block-status.model.ts | 15 + packages/models/src/moderation/index.ts | 4 + .../models/src/moderation/server-block.model.ts | 9 + packages/models/src/nodeinfo/index.ts | 1 + packages/models/src/nodeinfo/nodeinfo.model.ts | 117 ++ packages/models/src/overviews/index.ts | 1 + .../models/src/overviews/videos-overview.model.ts | 24 + .../models/src/plugins/client/client-hook.model.ts | 195 ++++ packages/models/src/plugins/client/index.ts | 8 + .../src/plugins/client/plugin-client-scope.type.ts | 11 + .../client/plugin-element-placeholder.type.ts | 4 + .../src/plugins/client/plugin-selector-id.type.ts | 10 + .../client/register-client-form-field.model.ts | 30 + .../plugins/client/register-client-hook.model.ts | 7 + .../plugins/client/register-client-route.model.ts | 7 + .../register-client-settings-script.model.ts | 8 + packages/models/src/plugins/hook-type.enum.ts | 7 + packages/models/src/plugins/index.ts | 6 + packages/models/src/plugins/plugin-index/index.ts | 3 + .../peertube-plugin-index-list.model.ts | 10 + .../plugin-index/peertube-plugin-index.model.ts | 16 + .../peertube-plugin-latest-version.model.ts | 10 + .../src/plugins/plugin-package-json.model.ts | 29 + packages/models/src/plugins/plugin.type.ts | 6 + packages/models/src/plugins/server/api/index.ts | 3 + .../src/plugins/server/api/install-plugin.model.ts | 5 + .../src/plugins/server/api/manage-plugin.model.ts | 3 + .../plugins/server/api/peertube-plugin.model.ts | 16 + packages/models/src/plugins/server/index.ts | 7 + .../models/src/plugins/server/managers/index.ts | 9 + .../plugin-playlist-privacy-manager.model.ts | 12 + .../managers/plugin-settings-manager.model.ts | 17 + .../managers/plugin-storage-manager.model.ts | 5 + .../managers/plugin-transcoding-manager.model.ts | 13 + .../plugin-video-category-manager.model.ts | 13 + .../plugin-video-language-manager.model.ts | 13 + .../managers/plugin-video-licence-manager.model.ts | 13 + .../managers/plugin-video-privacy-manager.model.ts | 13 + .../server/plugin-constant-manager.model.ts | 7 + .../src/plugins/server/plugin-translation.model.ts | 5 + .../plugins/server/register-server-hook.model.ts | 7 + .../models/src/plugins/server/server-hook.model.ts | 221 ++++ .../models/src/plugins/server/settings/index.ts | 2 + .../server/settings/public-server.setting.ts | 5 + .../settings/register-server-setting.model.ts | 12 + packages/models/src/redundancy/index.ts | 4 + .../redundancy/video-redundancies-filters.model.ts | 1 + .../video-redundancy-config-filter.type.ts | 1 + .../src/redundancy/video-redundancy.model.ts | 35 + .../redundancy/videos-redundancy-strategy.model.ts | 23 + .../src/runners/abort-runner-job-body.model.ts | 6 + .../src/runners/accept-runner-job-body.model.ts | 3 + .../src/runners/accept-runner-job-result.model.ts | 6 + .../src/runners/error-runner-job-body.model.ts | 6 + packages/models/src/runners/index.ts | 21 + .../src/runners/list-runner-jobs-query.model.ts | 9 + .../list-runner-registration-tokens.model.ts | 5 + .../models/src/runners/list-runners-query.model.ts | 5 + .../src/runners/register-runner-body.model.ts | 6 + .../src/runners/register-runner-result.model.ts | 4 + .../src/runners/request-runner-job-body.model.ts | 3 + .../src/runners/request-runner-job-result.model.ts | 10 + .../models/src/runners/runner-job-payload.model.ts | 79 ++ .../runners/runner-job-private-payload.model.ts | 45 + .../models/src/runners/runner-job-state.model.ts | 13 + .../src/runners/runner-job-success-body.model.ts | 46 + .../models/src/runners/runner-job-type.type.ts | 6 + .../src/runners/runner-job-update-body.model.ts | 28 + packages/models/src/runners/runner-job.model.ts | 45 + .../src/runners/runner-registration-token.ts | 10 + packages/models/src/runners/runner.model.ts | 12 + .../src/runners/unregister-runner-body.model.ts | 3 + .../models/src/search/boolean-both-query.model.ts | 2 + packages/models/src/search/index.ts | 6 + .../models/src/search/search-target-query.model.ts | 5 + .../search/video-channels-search-query.model.ts | 18 + .../search/video-playlists-search-query.model.ts | 20 + .../models/src/search/videos-common-query.model.ts | 45 + .../models/src/search/videos-search-query.model.ts | 26 + packages/models/src/server/about.model.ts | 20 + .../src/server/broadcast-message-level.type.ts | 1 + .../models/src/server/client-log-create.model.ts | 11 + .../models/src/server/client-log-level.type.ts | 1 + packages/models/src/server/contact-form.model.ts | 6 + packages/models/src/server/custom-config.model.ts | 259 +++++ packages/models/src/server/debug.model.ts | 12 + packages/models/src/server/emailer.model.ts | 49 + packages/models/src/server/index.ts | 16 + packages/models/src/server/job.model.ts | 303 +++++ .../src/server/peertube-problem-document.model.ts | 32 + packages/models/src/server/server-config.model.ts | 305 +++++ packages/models/src/server/server-debug.model.ts | 4 + .../models/src/server/server-error-code.enum.ts | 92 ++ .../src/server/server-follow-create.model.ts | 4 + .../models/src/server/server-log-level.type.ts | 1 + packages/models/src/server/server-stats.model.ts | 47 + packages/models/src/tokens/index.ts | 1 + .../models/src/tokens/oauth-client-local.model.ts | 4 + packages/models/src/users/index.ts | 16 + packages/models/src/users/registration/index.ts | 5 + .../src/users/registration/user-register.model.ts | 12 + .../user-registration-request.model.ts | 5 + .../registration/user-registration-state.model.ts | 7 + .../user-registration-update-state.model.ts | 4 + .../users/registration/user-registration.model.ts | 29 + .../src/users/two-factor-enable-result.model.ts | 7 + .../models/src/users/user-create-result.model.ts | 7 + packages/models/src/users/user-create.model.ts | 13 + packages/models/src/users/user-flag.model.ts | 6 + packages/models/src/users/user-login.model.ts | 5 + .../src/users/user-notification-setting.model.ts | 34 + .../models/src/users/user-notification.model.ts | 140 +++ .../models/src/users/user-refresh-token.model.ts | 4 + packages/models/src/users/user-right.enum.ts | 53 + packages/models/src/users/user-role.ts | 8 + packages/models/src/users/user-scoped-token.ts | 5 + packages/models/src/users/user-update-me.model.ts | 26 + packages/models/src/users/user-update.model.ts | 13 + .../models/src/users/user-video-quota.model.ts | 4 + packages/models/src/users/user.model.ts | 78 ++ packages/models/src/videos/blacklist/index.ts | 3 + .../blacklist/video-blacklist-create.model.ts | 4 + .../blacklist/video-blacklist-update.model.ts | 3 + .../src/videos/blacklist/video-blacklist.model.ts | 20 + packages/models/src/videos/caption/index.ts | 2 + .../videos/caption/video-caption-update.model.ts | 4 + .../src/videos/caption/video-caption.model.ts | 7 + .../models/src/videos/change-ownership/index.ts | 3 + .../video-change-ownership-accept.model.ts | 3 + .../video-change-ownership-create.model.ts | 3 + .../video-change-ownership.model.ts | 19 + packages/models/src/videos/channel-sync/index.ts | 3 + .../video-channel-sync-create.model.ts | 4 + .../channel-sync/video-channel-sync-state.enum.ts | 8 + .../channel-sync/video-channel-sync.model.ts | 14 + packages/models/src/videos/channel/index.ts | 4 + .../channel/video-channel-create-result.model.ts | 3 + .../videos/channel/video-channel-create.model.ts | 6 + .../videos/channel/video-channel-update.model.ts | 7 + .../src/videos/channel/video-channel.model.ts | 34 + packages/models/src/videos/comment/index.ts | 2 + .../videos/comment/video-comment-create.model.ts | 3 + .../src/videos/comment/video-comment.model.ts | 45 + packages/models/src/videos/file/index.ts | 3 + .../src/videos/file/video-file-metadata.model.ts | 13 + .../models/src/videos/file/video-file.model.ts | 22 + .../src/videos/file/video-resolution.enum.ts | 13 + packages/models/src/videos/import/index.ts | 4 + .../src/videos/import/video-import-create.model.ts | 9 + .../src/videos/import/video-import-state.enum.ts | 10 + .../models/src/videos/import/video-import.model.ts | 24 + .../videos-import-in-channel-create.model.ts | 4 + packages/models/src/videos/index.ts | 43 + packages/models/src/videos/live/index.ts | 8 + .../src/videos/live/live-video-create.model.ts | 11 + .../src/videos/live/live-video-error.enum.ts | 11 + .../videos/live/live-video-event-payload.model.ts | 7 + .../src/videos/live/live-video-event.type.ts | 1 + .../videos/live/live-video-latency-mode.enum.ts | 7 + .../src/videos/live/live-video-session.model.ts | 22 + .../src/videos/live/live-video-update.model.ts | 9 + .../models/src/videos/live/live-video.model.ts | 14 + packages/models/src/videos/nsfw-policy.type.ts | 1 + packages/models/src/videos/playlist/index.ts | 12 + .../playlist/video-exist-in-playlist.model.ts | 18 + .../playlist/video-playlist-create-result.model.ts | 5 + .../videos/playlist/video-playlist-create.model.ts | 11 + .../video-playlist-element-create-result.model.ts | 3 + .../video-playlist-element-create.model.ts | 6 + .../video-playlist-element-update.model.ts | 4 + .../playlist/video-playlist-element.model.ts | 21 + .../playlist/video-playlist-privacy.model.ts | 7 + .../playlist/video-playlist-reorder.model.ts | 5 + .../videos/playlist/video-playlist-type.model.ts | 6 + .../videos/playlist/video-playlist-update.model.ts | 10 + .../src/videos/playlist/video-playlist.model.ts | 35 + .../src/videos/rate/account-video-rate.model.ts | 7 + packages/models/src/videos/rate/index.ts | 5 + .../videos/rate/user-video-rate-update.model.ts | 5 + .../src/videos/rate/user-video-rate.model.ts | 6 + .../models/src/videos/rate/user-video-rate.type.ts | 1 + packages/models/src/videos/stats/index.ts | 6 + .../stats/video-stats-overall-query.model.ts | 4 + .../src/videos/stats/video-stats-overall.model.ts | 14 + .../videos/stats/video-stats-retention.model.ts | 6 + .../stats/video-stats-timeserie-metric.type.ts | 1 + .../stats/video-stats-timeserie-query.model.ts | 4 + .../videos/stats/video-stats-timeserie.model.ts | 8 + packages/models/src/videos/storyboard.model.ts | 11 + packages/models/src/videos/studio/index.ts | 1 + .../studio/video-studio-create-edit.model.ts | 60 + packages/models/src/videos/thumbnail.type.ts | 6 + packages/models/src/videos/transcoding/index.ts | 3 + .../transcoding/video-transcoding-create.model.ts | 5 + .../transcoding/video-transcoding-fps.model.ts | 9 + .../videos/transcoding/video-transcoding.model.ts | 65 ++ packages/models/src/videos/video-constant.model.ts | 5 + .../models/src/videos/video-create-result.model.ts | 5 + packages/models/src/videos/video-create.model.ts | 25 + packages/models/src/videos/video-include.enum.ts | 10 + packages/models/src/videos/video-password.model.ts | 7 + packages/models/src/videos/video-privacy.enum.ts | 9 + packages/models/src/videos/video-rate.type.ts | 1 + .../src/videos/video-schedule-update.model.ts | 7 + .../models/src/videos/video-sort-field.type.ts | 13 + packages/models/src/videos/video-source.model.ts | 4 + packages/models/src/videos/video-state.enum.ts | 13 + packages/models/src/videos/video-storage.enum.ts | 6 + .../src/videos/video-streaming-playlist.model.ts | 15 + .../src/videos/video-streaming-playlist.type.ts | 5 + packages/models/src/videos/video-token.model.ts | 6 + packages/models/src/videos/video-update.model.ts | 25 + packages/models/src/videos/video-view.model.ts | 6 + packages/models/src/videos/video.model.ts | 99 ++ packages/models/tsconfig.json | 8 + packages/models/tsconfig.types.json | 10 + packages/node-utils/package.json | 19 + packages/node-utils/src/crypto.ts | 20 + packages/node-utils/src/env.ts | 58 + packages/node-utils/src/file.ts | 11 + packages/node-utils/src/index.ts | 5 + packages/node-utils/src/path.ts | 50 + packages/node-utils/src/uuid.ts | 32 + packages/node-utils/tsconfig.json | 8 + packages/peertube-runner/.gitignore | 3 - packages/peertube-runner/.npmignore | 6 - packages/peertube-runner/README.md | 29 - packages/peertube-runner/package.json | 17 - packages/peertube-runner/peertube-runner.ts | 93 -- packages/peertube-runner/register/index.ts | 1 - packages/peertube-runner/register/register.ts | 36 - packages/peertube-runner/server/index.ts | 1 - packages/peertube-runner/server/process/index.ts | 2 - packages/peertube-runner/server/process/process.ts | 34 - .../server/process/shared/common.ts | 106 -- .../peertube-runner/server/process/shared/index.ts | 3 - .../server/process/shared/process-live.ts | 338 ------ .../server/process/shared/process-studio.ts | 165 --- .../server/process/shared/process-vod.ts | 201 ---- .../server/process/shared/transcoding-logger.ts | 10 - packages/peertube-runner/server/server.ts | 306 ----- packages/peertube-runner/server/shared/index.ts | 1 - .../peertube-runner/server/shared/supported-job.ts | 43 - packages/peertube-runner/shared/config-manager.ts | 139 --- packages/peertube-runner/shared/http.ts | 66 -- packages/peertube-runner/shared/index.ts | 3 - packages/peertube-runner/shared/ipc/index.ts | 2 - packages/peertube-runner/shared/ipc/ipc-client.ts | 88 -- packages/peertube-runner/shared/ipc/ipc-server.ts | 61 - .../peertube-runner/shared/ipc/shared/index.ts | 2 - .../shared/ipc/shared/ipc-request.model.ts | 15 - .../shared/ipc/shared/ipc-response.model.ts | 15 - packages/peertube-runner/shared/logger.ts | 12 - packages/peertube-runner/tsconfig.json | 9 - packages/peertube-runner/yarn.lock | 528 --------- packages/server-commands/package.json | 19 + packages/server-commands/src/bulk/bulk-command.ts | 20 + packages/server-commands/src/bulk/index.ts | 1 + packages/server-commands/src/cli/cli-command.ts | 27 + packages/server-commands/src/cli/index.ts | 1 + .../src/custom-pages/custom-pages-command.ts | 33 + packages/server-commands/src/custom-pages/index.ts | 1 + .../server-commands/src/feeds/feeds-command.ts | 78 ++ packages/server-commands/src/feeds/index.ts | 1 + packages/server-commands/src/index.ts | 14 + packages/server-commands/src/logs/index.ts | 1 + packages/server-commands/src/logs/logs-command.ts | 56 + .../src/moderation/abuses-command.ts | 228 ++++ packages/server-commands/src/moderation/index.ts | 1 + packages/server-commands/src/overviews/index.ts | 1 + .../src/overviews/overviews-command.ts | 23 + packages/server-commands/src/requests/index.ts | 1 + packages/server-commands/src/requests/requests.ts | 260 +++++ packages/server-commands/src/runners/index.ts | 3 + .../src/runners/runner-jobs-command.ts | 297 +++++ .../runners/runner-registration-tokens-command.ts | 55 + .../server-commands/src/runners/runners-command.ts | 85 ++ packages/server-commands/src/search/index.ts | 1 + .../server-commands/src/search/search-command.ts | 98 ++ .../server-commands/src/server/config-command.ts | 576 ++++++++++ .../src/server/contact-form-command.ts | 30 + .../server-commands/src/server/debug-command.ts | 33 + .../server-commands/src/server/follows-command.ts | 139 +++ packages/server-commands/src/server/follows.ts | 20 + packages/server-commands/src/server/index.ts | 15 + .../server-commands/src/server/jobs-command.ts | 84 ++ packages/server-commands/src/server/jobs.ts | 117 ++ .../server-commands/src/server/metrics-command.ts | 18 + .../src/server/object-storage-command.ts | 165 +++ .../server-commands/src/server/plugins-command.ts | 258 +++++ .../src/server/redundancy-command.ts | 80 ++ packages/server-commands/src/server/server.ts | 451 ++++++++ .../server-commands/src/server/servers-command.ts | 104 ++ packages/server-commands/src/server/servers.ts | 68 ++ .../server-commands/src/server/stats-command.ts | 25 + .../server-commands/src/shared/abstract-command.ts | 225 ++++ packages/server-commands/src/shared/index.ts | 1 + packages/server-commands/src/socket/index.ts | 1 + .../src/socket/socket-io-command.ts | 24 + .../server-commands/src/users/accounts-command.ts | 76 ++ packages/server-commands/src/users/accounts.ts | 15 + .../server-commands/src/users/blocklist-command.ts | 165 +++ packages/server-commands/src/users/index.ts | 10 + .../server-commands/src/users/login-command.ts | 159 +++ packages/server-commands/src/users/login.ts | 19 + .../src/users/notifications-command.ts | 85 ++ .../src/users/registrations-command.ts | 157 +++ .../src/users/subscriptions-command.ts | 83 ++ .../src/users/two-factor-command.ts | 92 ++ .../server-commands/src/users/users-command.ts | 389 +++++++ .../src/videos/blacklist-command.ts | 74 ++ .../server-commands/src/videos/captions-command.ts | 67 ++ .../src/videos/change-ownership-command.ts | 67 ++ .../src/videos/channel-syncs-command.ts | 55 + .../server-commands/src/videos/channels-command.ts | 202 ++++ packages/server-commands/src/videos/channels.ts | 29 + .../server-commands/src/videos/comments-command.ts | 159 +++ .../server-commands/src/videos/history-command.ts | 54 + .../server-commands/src/videos/imports-command.ts | 76 ++ packages/server-commands/src/videos/index.ts | 22 + .../server-commands/src/videos/live-command.ts | 339 ++++++ packages/server-commands/src/videos/live.ts | 129 +++ .../src/videos/playlists-command.ts | 281 +++++ .../server-commands/src/videos/services-command.ts | 29 + .../src/videos/storyboard-command.ts | 19 + .../src/videos/streaming-playlists-command.ts | 119 ++ .../src/videos/video-passwords-command.ts | 56 + .../src/videos/video-stats-command.ts | 62 + .../src/videos/video-studio-command.ts | 67 ++ .../src/videos/video-token-command.ts | 34 + .../server-commands/src/videos/videos-command.ts | 831 ++++++++++++++ .../server-commands/src/videos/views-command.ts | 51 + packages/server-commands/tsconfig.json | 14 + packages/tests/fixtures/60fps_720p_small.mp4 | Bin 0 -> 276786 bytes .../ap-json/mastodon/bad-body-http-signature.json | 93 ++ .../ap-json/mastodon/bad-http-signature.json | 93 ++ .../fixtures/ap-json/mastodon/bad-public-key.json | 3 + .../ap-json/mastodon/create-bad-signature.json | 81 ++ .../tests/fixtures/ap-json/mastodon/create.json | 81 ++ .../fixtures/ap-json/mastodon/http-signature.json | 93 ++ .../fixtures/ap-json/mastodon/public-key.json | 3 + .../ap-json/peertube/announce-without-context.json | 13 + .../fixtures/ap-json/peertube/invalid-keys.json | 6 + packages/tests/fixtures/ap-json/peertube/keys.json | 4 + packages/tests/fixtures/avatar-big.png | Bin 0 -> 146585 bytes packages/tests/fixtures/avatar-resized-120x120.gif | Bin 0 -> 88318 bytes packages/tests/fixtures/avatar-resized-120x120.png | Bin 0 -> 1727 bytes packages/tests/fixtures/avatar-resized-48x48.gif | Bin 0 -> 20462 bytes packages/tests/fixtures/avatar-resized-48x48.png | Bin 0 -> 727 bytes packages/tests/fixtures/avatar.gif | Bin 0 -> 46917 bytes packages/tests/fixtures/avatar.png | Bin 0 -> 1674 bytes .../tests/fixtures/avatar2-resized-120x120.png | Bin 0 -> 1725 bytes packages/tests/fixtures/avatar2-resized-48x48.png | Bin 0 -> 760 bytes packages/tests/fixtures/avatar2.png | Bin 0 -> 4850 bytes packages/tests/fixtures/banner-resized.jpg | Bin 0 -> 59947 bytes packages/tests/fixtures/banner.jpg | Bin 0 -> 31648 bytes packages/tests/fixtures/custom-preview-big.png | Bin 0 -> 536513 bytes packages/tests/fixtures/custom-preview.jpg | Bin 0 -> 14146 bytes packages/tests/fixtures/custom-thumbnail-big.jpg | Bin 0 -> 20379 bytes packages/tests/fixtures/custom-thumbnail.jpg | Bin 0 -> 6898 bytes packages/tests/fixtures/custom-thumbnail.png | Bin 0 -> 18070 bytes packages/tests/fixtures/exif.jpg | Bin 0 -> 10877 bytes packages/tests/fixtures/exif.png | Bin 0 -> 21059 bytes packages/tests/fixtures/live/0-000067.ts | Bin 0 -> 270532 bytes packages/tests/fixtures/live/0-000068.ts | Bin 0 -> 181420 bytes packages/tests/fixtures/live/0-000069.ts | Bin 0 -> 345732 bytes packages/tests/fixtures/live/0-000070.ts | Bin 0 -> 282376 bytes packages/tests/fixtures/live/0.m3u8 | 14 + packages/tests/fixtures/live/1-000067.ts | Bin 0 -> 620024 bytes packages/tests/fixtures/live/1-000068.ts | Bin 0 -> 382392 bytes packages/tests/fixtures/live/1-000069.ts | Bin 0 -> 712332 bytes packages/tests/fixtures/live/1-000070.ts | Bin 0 -> 608556 bytes packages/tests/fixtures/live/1.m3u8 | 14 + packages/tests/fixtures/live/master.m3u8 | 8 + packages/tests/fixtures/low-bitrate.mp4 | Bin 0 -> 43850 bytes .../fixtures/peertube-plugin-test-broken/main.js | 12 + .../peertube-plugin-test-broken/package.json | 20 + .../peertube-plugin-test-external-auth-one/main.js | 85 ++ .../package.json | 20 + .../main.js | 53 + .../package.json | 20 + .../peertube-plugin-test-external-auth-two/main.js | 95 ++ .../package.json | 20 + .../languages/fr.json | 3 + .../languages/it.json | 3 + .../main.js | 21 + .../package.json | 23 + .../fixtures/peertube-plugin-test-five/main.js | 23 + .../peertube-plugin-test-five/package.json | 20 + .../fixtures/peertube-plugin-test-four/main.js | 201 ++++ .../peertube-plugin-test-four/package.json | 20 + .../peertube-plugin-test-id-pass-auth-one/main.js | 69 ++ .../package.json | 20 + .../main.js | 106 ++ .../package.json | 20 + .../peertube-plugin-test-id-pass-auth-two/main.js | 65 ++ .../package.json | 20 + .../fixtures/peertube-plugin-test-native/main.js | 21 + .../peertube-plugin-test-native/package.json | 23 + .../main.js | 82 ++ .../package.json | 19 + .../fixtures/peertube-plugin-test-six/main.js | 46 + .../fixtures/peertube-plugin-test-six/package.json | 20 + .../peertube-plugin-test-transcoding-one/main.js | 92 ++ .../package.json | 20 + .../peertube-plugin-test-transcoding-two/main.js | 38 + .../package.json | 20 + .../fixtures/peertube-plugin-test-unloading/lib.js | 2 + .../peertube-plugin-test-unloading/main.js | 14 + .../peertube-plugin-test-unloading/package.json | 20 + .../peertube-plugin-test-video-constants/main.js | 46 + .../package.json | 20 + .../peertube-plugin-test-websocket/main.js | 36 + .../peertube-plugin-test-websocket/package.json | 20 + .../peertube-plugin-test/languages/fr.json | 3 + .../tests/fixtures/peertube-plugin-test/main.js | 477 ++++++++ .../fixtures/peertube-plugin-test/package.json | 22 + packages/tests/fixtures/rtmps.cert | 21 + packages/tests/fixtures/rtmps.key | 28 + packages/tests/fixtures/sample.ogg | Bin 0 -> 105243 bytes packages/tests/fixtures/subtitle-bad.txt | 11 + packages/tests/fixtures/subtitle-good.srt | 11 + packages/tests/fixtures/subtitle-good1.vtt | 8 + packages/tests/fixtures/subtitle-good2.vtt | 8 + packages/tests/fixtures/thumbnail-playlist.jpg | Bin 0 -> 5040 bytes packages/tests/fixtures/video-720p.torrent | Bin 0 -> 2644 bytes packages/tests/fixtures/video_import_preview.jpg | Bin 0 -> 9551 bytes .../tests/fixtures/video_import_preview_yt_dlp.jpg | Bin 0 -> 15844 bytes packages/tests/fixtures/video_import_thumbnail.jpg | Bin 0 -> 10980 bytes .../fixtures/video_import_thumbnail_yt_dlp.jpg | Bin 0 -> 10676 bytes packages/tests/fixtures/video_short.avi | Bin 0 -> 584656 bytes packages/tests/fixtures/video_short.mkv | Bin 0 -> 40642 bytes packages/tests/fixtures/video_short.mp4 | Bin 0 -> 38783 bytes packages/tests/fixtures/video_short.mp4.jpg | Bin 0 -> 5028 bytes packages/tests/fixtures/video_short.ogv | Bin 0 -> 140849 bytes packages/tests/fixtures/video_short.ogv.jpg | Bin 0 -> 5023 bytes packages/tests/fixtures/video_short.webm | Bin 0 -> 218910 bytes packages/tests/fixtures/video_short.webm.jpg | Bin 0 -> 5028 bytes .../tests/fixtures/video_short1-preview.webm.jpg | Bin 0 -> 31188 bytes packages/tests/fixtures/video_short1.webm | Bin 0 -> 572456 bytes packages/tests/fixtures/video_short1.webm.jpg | Bin 0 -> 6334 bytes packages/tests/fixtures/video_short2.webm | Bin 0 -> 942961 bytes packages/tests/fixtures/video_short2.webm.jpg | Bin 0 -> 6607 bytes packages/tests/fixtures/video_short3.webm | Bin 0 -> 292677 bytes packages/tests/fixtures/video_short3.webm.jpg | Bin 0 -> 5674 bytes packages/tests/fixtures/video_short_0p.mp4 | Bin 0 -> 3051 bytes packages/tests/fixtures/video_short_144p.m3u8 | 13 + packages/tests/fixtures/video_short_144p.mp4 | Bin 0 -> 15634 bytes packages/tests/fixtures/video_short_240p.m3u8 | 13 + packages/tests/fixtures/video_short_240p.mp4 | Bin 0 -> 23084 bytes packages/tests/fixtures/video_short_360p.m3u8 | 13 + packages/tests/fixtures/video_short_360p.mp4 | Bin 0 -> 30620 bytes packages/tests/fixtures/video_short_480.webm | Bin 0 -> 69217 bytes packages/tests/fixtures/video_short_480p.m3u8 | 13 + packages/tests/fixtures/video_short_480p.mp4 | Bin 0 -> 39881 bytes packages/tests/fixtures/video_short_4k.mp4 | Bin 0 -> 618949 bytes packages/tests/fixtures/video_short_720p.m3u8 | 13 + packages/tests/fixtures/video_short_720p.mp4 | Bin 0 -> 59109 bytes packages/tests/fixtures/video_short_fake.webm | 1 + packages/tests/fixtures/video_short_mp3_256k.mp4 | Bin 0 -> 194985 bytes packages/tests/fixtures/video_short_no_audio.mp4 | Bin 0 -> 34259 bytes packages/tests/fixtures/video_very_long_10p.mp4 | Bin 0 -> 185338 bytes packages/tests/fixtures/video_very_short_240p.mp4 | Bin 0 -> 9352 bytes packages/tests/package.json | 12 + packages/tests/src/api/activitypub/cleaner.ts | 342 ++++++ packages/tests/src/api/activitypub/client.ts | 136 +++ packages/tests/src/api/activitypub/fetch.ts | 82 ++ packages/tests/src/api/activitypub/index.ts | 5 + packages/tests/src/api/activitypub/refresher.ts | 157 +++ packages/tests/src/api/activitypub/security.ts | 331 ++++++ packages/tests/src/api/check-params/abuses.ts | 438 +++++++ packages/tests/src/api/check-params/accounts.ts | 43 + packages/tests/src/api/check-params/blocklist.ts | 556 +++++++++ packages/tests/src/api/check-params/bulk.ts | 86 ++ .../src/api/check-params/channel-import-videos.ts | 209 ++++ packages/tests/src/api/check-params/config.ts | 428 +++++++ .../tests/src/api/check-params/contact-form.ts | 86 ++ .../tests/src/api/check-params/custom-pages.ts | 79 ++ packages/tests/src/api/check-params/debug.ts | 67 ++ packages/tests/src/api/check-params/follows.ts | 369 ++++++ packages/tests/src/api/check-params/index.ts | 45 + packages/tests/src/api/check-params/jobs.ts | 125 ++ packages/tests/src/api/check-params/live.ts | 590 ++++++++++ packages/tests/src/api/check-params/logs.ts | 163 +++ packages/tests/src/api/check-params/metrics.ts | 214 ++++ packages/tests/src/api/check-params/my-user.ts | 492 ++++++++ packages/tests/src/api/check-params/plugins.ts | 490 ++++++++ packages/tests/src/api/check-params/redundancy.ts | 240 ++++ .../tests/src/api/check-params/registrations.ts | 446 ++++++++ packages/tests/src/api/check-params/runners.ts | 911 +++++++++++++++ packages/tests/src/api/check-params/search.ts | 278 +++++ packages/tests/src/api/check-params/services.ts | 207 ++++ packages/tests/src/api/check-params/transcoding.ts | 112 ++ packages/tests/src/api/check-params/two-factor.ts | 294 +++++ .../tests/src/api/check-params/upload-quota.ts | 134 +++ .../src/api/check-params/user-notifications.ts | 290 +++++ .../src/api/check-params/user-subscriptions.ts | 298 +++++ packages/tests/src/api/check-params/users-admin.ts | 457 ++++++++ .../tests/src/api/check-params/users-emails.ts | 122 ++ .../tests/src/api/check-params/video-blacklist.ts | 292 +++++ .../tests/src/api/check-params/video-captions.ts | 307 +++++ .../src/api/check-params/video-channel-syncs.ts | 319 ++++++ .../tests/src/api/check-params/video-channels.ts | 379 ++++++ .../tests/src/api/check-params/video-comments.ts | 484 ++++++++ packages/tests/src/api/check-params/video-files.ts | 195 ++++ .../tests/src/api/check-params/video-imports.ts | 433 +++++++ .../tests/src/api/check-params/video-passwords.ts | 604 ++++++++++ .../tests/src/api/check-params/video-playlists.ts | 695 +++++++++++ .../tests/src/api/check-params/video-source.ts | 154 +++ .../src/api/check-params/video-storyboards.ts | 45 + .../tests/src/api/check-params/video-studio.ts | 392 +++++++ packages/tests/src/api/check-params/video-token.ts | 70 ++ .../src/api/check-params/videos-common-filters.ts | 171 +++ .../tests/src/api/check-params/videos-history.ts | 145 +++ .../tests/src/api/check-params/videos-overviews.ts | 31 + packages/tests/src/api/check-params/videos.ts | 883 ++++++++++++++ packages/tests/src/api/check-params/views.ts | 227 ++++ packages/tests/src/api/live/index.ts | 7 + packages/tests/src/api/live/live-constraints.ts | 237 ++++ packages/tests/src/api/live/live-fast-restream.ts | 153 +++ packages/tests/src/api/live/live-permanent.ts | 204 ++++ packages/tests/src/api/live/live-rtmps.ts | 143 +++ packages/tests/src/api/live/live-save-replay.ts | 583 ++++++++++ .../tests/src/api/live/live-socket-messages.ts | 186 +++ packages/tests/src/api/live/live.ts | 766 +++++++++++++ packages/tests/src/api/moderation/abuses.ts | 887 ++++++++++++++ .../src/api/moderation/blocklist-notification.ts | 231 ++++ packages/tests/src/api/moderation/blocklist.ts | 902 +++++++++++++++ packages/tests/src/api/moderation/index.ts | 4 + .../tests/src/api/moderation/video-blacklist.ts | 414 +++++++ .../src/api/notifications/admin-notifications.ts | 154 +++ .../api/notifications/comments-notifications.ts | 300 +++++ packages/tests/src/api/notifications/index.ts | 6 + .../api/notifications/moderation-notifications.ts | 609 ++++++++++ .../src/api/notifications/notifications-api.ts | 206 ++++ .../notifications/registrations-notifications.ts | 83 ++ .../src/api/notifications/user-notifications.ts | 574 ++++++++++ packages/tests/src/api/object-storage/index.ts | 4 + packages/tests/src/api/object-storage/live.ts | 314 +++++ .../tests/src/api/object-storage/video-imports.ts | 112 ++ .../object-storage/video-static-file-privacy.ts | 573 +++++++++ packages/tests/src/api/object-storage/videos.ts | 434 +++++++ packages/tests/src/api/redundancy/index.ts | 3 + .../tests/src/api/redundancy/manage-redundancy.ts | 324 ++++++ .../src/api/redundancy/redundancy-constraints.ts | 191 +++ packages/tests/src/api/redundancy/redundancy.ts | 743 ++++++++++++ packages/tests/src/api/runners/index.ts | 5 + packages/tests/src/api/runners/runner-common.ts | 744 ++++++++++++ .../src/api/runners/runner-live-transcoding.ts | 332 ++++++ packages/tests/src/api/runners/runner-socket.ts | 120 ++ .../src/api/runners/runner-studio-transcoding.ts | 169 +++ .../src/api/runners/runner-vod-transcoding.ts | 545 +++++++++ packages/tests/src/api/search/index.ts | 7 + .../search/search-activitypub-video-channels.ts | 255 +++++ .../search/search-activitypub-video-playlists.ts | 214 ++++ .../src/api/search/search-activitypub-videos.ts | 196 ++++ packages/tests/src/api/search/search-channels.ts | 159 +++ packages/tests/src/api/search/search-index.ts | 438 +++++++ packages/tests/src/api/search/search-playlists.ts | 180 +++ packages/tests/src/api/search/search-videos.ts | 568 +++++++++ packages/tests/src/api/server/auto-follows.ts | 189 +++ packages/tests/src/api/server/bulk.ts | 185 +++ packages/tests/src/api/server/config-defaults.ts | 294 +++++ packages/tests/src/api/server/config.ts | 645 +++++++++++ packages/tests/src/api/server/contact-form.ts | 101 ++ packages/tests/src/api/server/email.ts | 371 ++++++ .../tests/src/api/server/follow-constraints.ts | 321 ++++++ .../tests/src/api/server/follows-moderation.ts | 364 ++++++ packages/tests/src/api/server/follows.ts | 644 +++++++++++ packages/tests/src/api/server/handle-down.ts | 339 ++++++ packages/tests/src/api/server/homepage.ts | 81 ++ packages/tests/src/api/server/index.ts | 22 + packages/tests/src/api/server/jobs.ts | 128 +++ packages/tests/src/api/server/logs.ts | 265 +++++ packages/tests/src/api/server/no-client.ts | 24 + packages/tests/src/api/server/open-telemetry.ts | 193 ++++ packages/tests/src/api/server/plugins.ts | 410 +++++++ packages/tests/src/api/server/proxy.ts | 173 +++ packages/tests/src/api/server/reverse-proxy.ts | 156 +++ packages/tests/src/api/server/services.ts | 143 +++ packages/tests/src/api/server/slow-follows.ts | 85 ++ packages/tests/src/api/server/stats.ts | 279 +++++ packages/tests/src/api/server/tracker.ts | 110 ++ packages/tests/src/api/transcoding/audio-only.ts | 104 ++ .../src/api/transcoding/create-transcoding.ts | 267 +++++ packages/tests/src/api/transcoding/hls.ts | 176 +++ packages/tests/src/api/transcoding/index.ts | 6 + packages/tests/src/api/transcoding/transcoder.ts | 802 +++++++++++++ .../api/transcoding/update-while-transcoding.ts | 161 +++ packages/tests/src/api/transcoding/video-studio.ts | 379 ++++++ packages/tests/src/api/users/index.ts | 8 + packages/tests/src/api/users/oauth.ts | 203 ++++ packages/tests/src/api/users/registrations.ts | 415 +++++++ packages/tests/src/api/users/two-factor.ts | 206 ++++ packages/tests/src/api/users/user-subscriptions.ts | 614 ++++++++++ packages/tests/src/api/users/user-videos.ts | 219 ++++ .../src/api/users/users-email-verification.ts | 165 +++ .../tests/src/api/users/users-multiple-servers.ts | 213 ++++ packages/tests/src/api/users/users.ts | 529 +++++++++ .../tests/src/api/videos/channel-import-videos.ts | 161 +++ packages/tests/src/api/videos/index.ts | 23 + packages/tests/src/api/videos/multiple-servers.ts | 1095 ++++++++++++++++++ packages/tests/src/api/videos/resumable-upload.ts | 316 +++++ packages/tests/src/api/videos/single-server.ts | 461 ++++++++ packages/tests/src/api/videos/video-captions.ts | 189 +++ .../tests/src/api/videos/video-change-ownership.ts | 314 +++++ .../tests/src/api/videos/video-channel-syncs.ts | 321 ++++++ packages/tests/src/api/videos/video-channels.ts | 556 +++++++++ packages/tests/src/api/videos/video-comments.ts | 335 ++++++ packages/tests/src/api/videos/video-description.ts | 103 ++ packages/tests/src/api/videos/video-files.ts | 202 ++++ packages/tests/src/api/videos/video-imports.ts | 634 ++++++++++ packages/tests/src/api/videos/video-nsfw.ts | 227 ++++ packages/tests/src/api/videos/video-passwords.ts | 97 ++ .../src/api/videos/video-playlist-thumbnails.ts | 234 ++++ packages/tests/src/api/videos/video-playlists.ts | 1210 ++++++++++++++++++++ packages/tests/src/api/videos/video-privacy.ts | 294 +++++ .../tests/src/api/videos/video-schedule-update.ts | 155 +++ packages/tests/src/api/videos/video-source.ts | 448 ++++++++ .../src/api/videos/video-static-file-privacy.ts | 602 ++++++++++ packages/tests/src/api/videos/video-storyboard.ts | 213 ++++ .../tests/src/api/videos/videos-common-filters.ts | 499 ++++++++ packages/tests/src/api/videos/videos-history.ts | 230 ++++ packages/tests/src/api/videos/videos-overview.ts | 129 +++ packages/tests/src/api/views/index.ts | 5 + .../tests/src/api/views/video-views-counter.ts | 153 +++ .../src/api/views/video-views-overall-stats.ts | 368 ++++++ .../src/api/views/video-views-retention-stats.ts | 53 + .../src/api/views/video-views-timeserie-stats.ts | 253 ++++ .../tests/src/api/views/videos-views-cleaner.ts | 98 ++ .../src/cli/create-generate-storyboard-job.ts | 121 ++ .../tests/src/cli/create-import-video-file-job.ts | 168 +++ .../tests/src/cli/create-move-video-storage-job.ts | 125 ++ packages/tests/src/cli/index.ts | 10 + packages/tests/src/cli/peertube.ts | 257 +++++ packages/tests/src/cli/plugins.ts | 76 ++ packages/tests/src/cli/prune-storage.ts | 224 ++++ packages/tests/src/cli/regenerate-thumbnails.ts | 122 ++ packages/tests/src/cli/reset-password.ts | 26 + packages/tests/src/cli/update-host.ts | 134 +++ packages/tests/src/client.ts | 556 +++++++++ packages/tests/src/external-plugins/akismet.ts | 160 +++ packages/tests/src/external-plugins/auth-ldap.ts | 117 ++ .../src/external-plugins/auto-block-videos.ts | 167 +++ packages/tests/src/external-plugins/auto-mute.ts | 216 ++++ packages/tests/src/external-plugins/index.ts | 4 + packages/tests/src/feeds/feeds.ts | 697 +++++++++++ packages/tests/src/feeds/index.ts | 1 + packages/tests/src/misc-endpoints.ts | 243 ++++ packages/tests/src/peertube-runner/client-cli.ts | 76 ++ packages/tests/src/peertube-runner/index.ts | 4 + .../tests/src/peertube-runner/live-transcoding.ts | 200 ++++ .../src/peertube-runner/studio-transcoding.ts | 127 ++ .../tests/src/peertube-runner/vod-transcoding.ts | 349 ++++++ packages/tests/src/plugins/action-hooks.ts | 298 +++++ packages/tests/src/plugins/external-auth.ts | 436 +++++++ packages/tests/src/plugins/filter-hooks.ts | 909 +++++++++++++++ packages/tests/src/plugins/html-injection.ts | 73 ++ packages/tests/src/plugins/id-and-pass-auth.ts | 248 ++++ packages/tests/src/plugins/index.ts | 13 + packages/tests/src/plugins/plugin-helpers.ts | 383 +++++++ packages/tests/src/plugins/plugin-router.ts | 105 ++ packages/tests/src/plugins/plugin-storage.ts | 95 ++ packages/tests/src/plugins/plugin-transcoding.ts | 279 +++++ packages/tests/src/plugins/plugin-unloading.ts | 75 ++ packages/tests/src/plugins/plugin-websocket.ts | 76 ++ packages/tests/src/plugins/translations.ts | 80 ++ packages/tests/src/plugins/video-constants.ts | 180 +++ packages/tests/src/server-helpers/activitypub.ts | 176 +++ packages/tests/src/server-helpers/core-utils.ts | 150 +++ packages/tests/src/server-helpers/crypto.ts | 33 + packages/tests/src/server-helpers/dns.ts | 16 + packages/tests/src/server-helpers/image.ts | 97 ++ packages/tests/src/server-helpers/index.ts | 10 + packages/tests/src/server-helpers/markdown.ts | 39 + packages/tests/src/server-helpers/mentions.ts | 17 + packages/tests/src/server-helpers/request.ts | 64 ++ packages/tests/src/server-helpers/validator.ts | 35 + packages/tests/src/server-helpers/version.ts | 36 + packages/tests/src/server-lib/index.ts | 1 + .../server-lib/video-constant-registry-factory.ts | 151 +++ packages/tests/src/shared/actors.ts | 70 ++ packages/tests/src/shared/captions.ts | 21 + packages/tests/src/shared/checks.ts | 177 +++ packages/tests/src/shared/directories.ts | 44 + packages/tests/src/shared/generate.ts | 79 ++ packages/tests/src/shared/live.ts | 186 +++ packages/tests/src/shared/mock-servers/index.ts | 8 + packages/tests/src/shared/mock-servers/mock-429.ts | 33 + .../tests/src/shared/mock-servers/mock-email.ts | 62 + .../tests/src/shared/mock-servers/mock-http.ts | 23 + .../shared/mock-servers/mock-instances-index.ts | 46 + .../mock-servers/mock-joinpeertube-versions.ts | 34 + .../src/shared/mock-servers/mock-object-storage.ts | 41 + .../shared/mock-servers/mock-plugin-blocklist.ts | 36 + .../tests/src/shared/mock-servers/mock-proxy.ts | 24 + packages/tests/src/shared/mock-servers/shared.ts | 33 + packages/tests/src/shared/notifications.ts | 891 ++++++++++++++ .../tests/src/shared/peertube-runner-process.ts | 104 ++ packages/tests/src/shared/plugins.ts | 18 + packages/tests/src/shared/requests.ts | 12 + packages/tests/src/shared/sql-command.ts | 150 +++ packages/tests/src/shared/streaming-playlists.ts | 302 +++++ packages/tests/src/shared/tests.ts | 40 + packages/tests/src/shared/tracker.ts | 27 + packages/tests/src/shared/video-playlists.ts | 22 + packages/tests/src/shared/videos.ts | 323 ++++++ packages/tests/src/shared/views.ts | 93 ++ packages/tests/src/shared/webtorrent.ts | 58 + packages/tests/tsconfig.json | 27 + packages/types-generator/README.md | 19 + packages/types-generator/generate-package.ts | 107 ++ packages/types-generator/package.json | 9 + packages/types-generator/src/client/index.ts | 1 + .../types-generator/src/client/tsconfig.types.json | 19 + packages/types-generator/src/index.ts | 3 + packages/types-generator/tests/test.ts | 32 + packages/types-generator/tsconfig.dist.json | 17 + packages/types-generator/tsconfig.json | 11 + packages/types-generator/tsconfig.types.json | 23 + packages/types/README.md | 19 - packages/types/generate-package.ts | 96 -- packages/types/src/client/index.ts | 1 - packages/types/src/client/tsconfig.json | 15 - packages/types/src/index.ts | 3 - packages/types/tests/test.ts | 32 - packages/types/tsconfig.dist.json | 16 - packages/types/tsconfig.json | 23 - packages/typescript-utils/package.json | 19 + packages/typescript-utils/src/index.ts | 1 + packages/typescript-utils/src/types.ts | 45 + packages/typescript-utils/tsconfig.json | 8 + packages/typescript-utils/tsconfig.types.json | 10 + 829 files changed, 78813 insertions(+), 2540 deletions(-) create mode 100644 packages/core-utils/package.json create mode 100644 packages/core-utils/src/abuse/abuse-predefined-reasons.ts create mode 100644 packages/core-utils/src/abuse/index.ts create mode 100644 packages/core-utils/src/common/array.ts create mode 100644 packages/core-utils/src/common/date.ts create mode 100644 packages/core-utils/src/common/index.ts create mode 100644 packages/core-utils/src/common/number.ts create mode 100644 packages/core-utils/src/common/object.ts create mode 100644 packages/core-utils/src/common/promises.ts create mode 100644 packages/core-utils/src/common/random.ts create mode 100644 packages/core-utils/src/common/regexp.ts create mode 100644 packages/core-utils/src/common/time.ts create mode 100644 packages/core-utils/src/common/url.ts create mode 100644 packages/core-utils/src/common/version.ts create mode 100644 packages/core-utils/src/i18n/i18n.ts create mode 100644 packages/core-utils/src/i18n/index.ts create mode 100644 packages/core-utils/src/index.ts create mode 100644 packages/core-utils/src/plugins/hooks.ts create mode 100644 packages/core-utils/src/plugins/index.ts create mode 100644 packages/core-utils/src/renderer/html.ts create mode 100644 packages/core-utils/src/renderer/index.ts create mode 100644 packages/core-utils/src/renderer/markdown.ts create mode 100644 packages/core-utils/src/users/index.ts create mode 100644 packages/core-utils/src/users/user-role.ts create mode 100644 packages/core-utils/src/videos/bitrate.ts create mode 100644 packages/core-utils/src/videos/common.ts create mode 100644 packages/core-utils/src/videos/index.ts create mode 100644 packages/core-utils/tsconfig.json create mode 100644 packages/ffmpeg/package.json create mode 100644 packages/ffmpeg/src/ffmpeg-command-wrapper.ts create mode 100644 packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts create mode 100644 packages/ffmpeg/src/ffmpeg-edition.ts create mode 100644 packages/ffmpeg/src/ffmpeg-images.ts create mode 100644 packages/ffmpeg/src/ffmpeg-live.ts create mode 100644 packages/ffmpeg/src/ffmpeg-utils.ts create mode 100644 packages/ffmpeg/src/ffmpeg-version.ts create mode 100644 packages/ffmpeg/src/ffmpeg-vod.ts create mode 100644 packages/ffmpeg/src/ffprobe.ts create mode 100644 packages/ffmpeg/src/index.ts create mode 100644 packages/ffmpeg/src/shared/encoder-options.ts create mode 100644 packages/ffmpeg/src/shared/index.ts create mode 100644 packages/ffmpeg/src/shared/presets.ts create mode 100644 packages/ffmpeg/tsconfig.json create mode 100644 packages/models/package.json create mode 100644 packages/models/src/activitypub/activity.ts create mode 100644 packages/models/src/activitypub/activitypub-actor.ts create mode 100644 packages/models/src/activitypub/activitypub-collection.ts create mode 100644 packages/models/src/activitypub/activitypub-ordered-collection.ts create mode 100644 packages/models/src/activitypub/activitypub-root.ts create mode 100644 packages/models/src/activitypub/activitypub-signature.ts create mode 100644 packages/models/src/activitypub/context.ts create mode 100644 packages/models/src/activitypub/index.ts create mode 100644 packages/models/src/activitypub/objects/abuse-object.ts create mode 100644 packages/models/src/activitypub/objects/activitypub-object.ts create mode 100644 packages/models/src/activitypub/objects/cache-file-object.ts create mode 100644 packages/models/src/activitypub/objects/common-objects.ts create mode 100644 packages/models/src/activitypub/objects/index.ts create mode 100644 packages/models/src/activitypub/objects/playlist-element-object.ts create mode 100644 packages/models/src/activitypub/objects/playlist-object.ts create mode 100644 packages/models/src/activitypub/objects/video-comment-object.ts create mode 100644 packages/models/src/activitypub/objects/video-object.ts create mode 100644 packages/models/src/activitypub/objects/watch-action-object.ts create mode 100644 packages/models/src/activitypub/webfinger.ts create mode 100644 packages/models/src/actors/account.model.ts create mode 100644 packages/models/src/actors/actor-image.model.ts create mode 100644 packages/models/src/actors/actor-image.type.ts create mode 100644 packages/models/src/actors/actor.model.ts create mode 100644 packages/models/src/actors/custom-page.model.ts create mode 100644 packages/models/src/actors/follow.model.ts create mode 100644 packages/models/src/actors/index.ts create mode 100644 packages/models/src/bulk/bulk-remove-comments-of-body.model.ts create mode 100644 packages/models/src/bulk/index.ts create mode 100644 packages/models/src/common/index.ts create mode 100644 packages/models/src/common/result-list.model.ts create mode 100644 packages/models/src/custom-markup/custom-markup-data.model.ts create mode 100644 packages/models/src/custom-markup/index.ts create mode 100644 packages/models/src/feeds/feed-format.enum.ts create mode 100644 packages/models/src/feeds/index.ts create mode 100644 packages/models/src/http/http-methods.ts create mode 100644 packages/models/src/http/http-status-codes.ts create mode 100644 packages/models/src/http/index.ts create mode 100644 packages/models/src/index.ts create mode 100644 packages/models/src/joinpeertube/index.ts create mode 100644 packages/models/src/joinpeertube/versions.model.ts create mode 100644 packages/models/src/metrics/index.ts create mode 100644 packages/models/src/metrics/playback-metric-create.model.ts create mode 100644 packages/models/src/moderation/abuse/abuse-create.model.ts create mode 100644 packages/models/src/moderation/abuse/abuse-filter.type.ts create mode 100644 packages/models/src/moderation/abuse/abuse-message.model.ts create mode 100644 packages/models/src/moderation/abuse/abuse-reason.model.ts create mode 100644 packages/models/src/moderation/abuse/abuse-state.model.ts create mode 100644 packages/models/src/moderation/abuse/abuse-update.model.ts create mode 100644 packages/models/src/moderation/abuse/abuse-video-is.type.ts create mode 100644 packages/models/src/moderation/abuse/abuse.model.ts create mode 100644 packages/models/src/moderation/abuse/index.ts create mode 100644 packages/models/src/moderation/account-block.model.ts create mode 100644 packages/models/src/moderation/block-status.model.ts create mode 100644 packages/models/src/moderation/index.ts create mode 100644 packages/models/src/moderation/server-block.model.ts create mode 100644 packages/models/src/nodeinfo/index.ts create mode 100644 packages/models/src/nodeinfo/nodeinfo.model.ts create mode 100644 packages/models/src/overviews/index.ts create mode 100644 packages/models/src/overviews/videos-overview.model.ts create mode 100644 packages/models/src/plugins/client/client-hook.model.ts create mode 100644 packages/models/src/plugins/client/index.ts create mode 100644 packages/models/src/plugins/client/plugin-client-scope.type.ts create mode 100644 packages/models/src/plugins/client/plugin-element-placeholder.type.ts create mode 100644 packages/models/src/plugins/client/plugin-selector-id.type.ts create mode 100644 packages/models/src/plugins/client/register-client-form-field.model.ts create mode 100644 packages/models/src/plugins/client/register-client-hook.model.ts create mode 100644 packages/models/src/plugins/client/register-client-route.model.ts create mode 100644 packages/models/src/plugins/client/register-client-settings-script.model.ts create mode 100644 packages/models/src/plugins/hook-type.enum.ts create mode 100644 packages/models/src/plugins/index.ts create mode 100644 packages/models/src/plugins/plugin-index/index.ts create mode 100644 packages/models/src/plugins/plugin-index/peertube-plugin-index-list.model.ts create mode 100644 packages/models/src/plugins/plugin-index/peertube-plugin-index.model.ts create mode 100644 packages/models/src/plugins/plugin-index/peertube-plugin-latest-version.model.ts create mode 100644 packages/models/src/plugins/plugin-package-json.model.ts create mode 100644 packages/models/src/plugins/plugin.type.ts create mode 100644 packages/models/src/plugins/server/api/index.ts create mode 100644 packages/models/src/plugins/server/api/install-plugin.model.ts create mode 100644 packages/models/src/plugins/server/api/manage-plugin.model.ts create mode 100644 packages/models/src/plugins/server/api/peertube-plugin.model.ts create mode 100644 packages/models/src/plugins/server/index.ts create mode 100644 packages/models/src/plugins/server/managers/index.ts create mode 100644 packages/models/src/plugins/server/managers/plugin-playlist-privacy-manager.model.ts create mode 100644 packages/models/src/plugins/server/managers/plugin-settings-manager.model.ts create mode 100644 packages/models/src/plugins/server/managers/plugin-storage-manager.model.ts create mode 100644 packages/models/src/plugins/server/managers/plugin-transcoding-manager.model.ts create mode 100644 packages/models/src/plugins/server/managers/plugin-video-category-manager.model.ts create mode 100644 packages/models/src/plugins/server/managers/plugin-video-language-manager.model.ts create mode 100644 packages/models/src/plugins/server/managers/plugin-video-licence-manager.model.ts create mode 100644 packages/models/src/plugins/server/managers/plugin-video-privacy-manager.model.ts create mode 100644 packages/models/src/plugins/server/plugin-constant-manager.model.ts create mode 100644 packages/models/src/plugins/server/plugin-translation.model.ts create mode 100644 packages/models/src/plugins/server/register-server-hook.model.ts create mode 100644 packages/models/src/plugins/server/server-hook.model.ts create mode 100644 packages/models/src/plugins/server/settings/index.ts create mode 100644 packages/models/src/plugins/server/settings/public-server.setting.ts create mode 100644 packages/models/src/plugins/server/settings/register-server-setting.model.ts create mode 100644 packages/models/src/redundancy/index.ts create mode 100644 packages/models/src/redundancy/video-redundancies-filters.model.ts create mode 100644 packages/models/src/redundancy/video-redundancy-config-filter.type.ts create mode 100644 packages/models/src/redundancy/video-redundancy.model.ts create mode 100644 packages/models/src/redundancy/videos-redundancy-strategy.model.ts create mode 100644 packages/models/src/runners/abort-runner-job-body.model.ts create mode 100644 packages/models/src/runners/accept-runner-job-body.model.ts create mode 100644 packages/models/src/runners/accept-runner-job-result.model.ts create mode 100644 packages/models/src/runners/error-runner-job-body.model.ts create mode 100644 packages/models/src/runners/index.ts create mode 100644 packages/models/src/runners/list-runner-jobs-query.model.ts create mode 100644 packages/models/src/runners/list-runner-registration-tokens.model.ts create mode 100644 packages/models/src/runners/list-runners-query.model.ts create mode 100644 packages/models/src/runners/register-runner-body.model.ts create mode 100644 packages/models/src/runners/register-runner-result.model.ts create mode 100644 packages/models/src/runners/request-runner-job-body.model.ts create mode 100644 packages/models/src/runners/request-runner-job-result.model.ts create mode 100644 packages/models/src/runners/runner-job-payload.model.ts create mode 100644 packages/models/src/runners/runner-job-private-payload.model.ts create mode 100644 packages/models/src/runners/runner-job-state.model.ts create mode 100644 packages/models/src/runners/runner-job-success-body.model.ts create mode 100644 packages/models/src/runners/runner-job-type.type.ts create mode 100644 packages/models/src/runners/runner-job-update-body.model.ts create mode 100644 packages/models/src/runners/runner-job.model.ts create mode 100644 packages/models/src/runners/runner-registration-token.ts create mode 100644 packages/models/src/runners/runner.model.ts create mode 100644 packages/models/src/runners/unregister-runner-body.model.ts create mode 100644 packages/models/src/search/boolean-both-query.model.ts create mode 100644 packages/models/src/search/index.ts create mode 100644 packages/models/src/search/search-target-query.model.ts create mode 100644 packages/models/src/search/video-channels-search-query.model.ts create mode 100644 packages/models/src/search/video-playlists-search-query.model.ts create mode 100644 packages/models/src/search/videos-common-query.model.ts create mode 100644 packages/models/src/search/videos-search-query.model.ts create mode 100644 packages/models/src/server/about.model.ts create mode 100644 packages/models/src/server/broadcast-message-level.type.ts create mode 100644 packages/models/src/server/client-log-create.model.ts create mode 100644 packages/models/src/server/client-log-level.type.ts create mode 100644 packages/models/src/server/contact-form.model.ts create mode 100644 packages/models/src/server/custom-config.model.ts create mode 100644 packages/models/src/server/debug.model.ts create mode 100644 packages/models/src/server/emailer.model.ts create mode 100644 packages/models/src/server/index.ts create mode 100644 packages/models/src/server/job.model.ts create mode 100644 packages/models/src/server/peertube-problem-document.model.ts create mode 100644 packages/models/src/server/server-config.model.ts create mode 100644 packages/models/src/server/server-debug.model.ts create mode 100644 packages/models/src/server/server-error-code.enum.ts create mode 100644 packages/models/src/server/server-follow-create.model.ts create mode 100644 packages/models/src/server/server-log-level.type.ts create mode 100644 packages/models/src/server/server-stats.model.ts create mode 100644 packages/models/src/tokens/index.ts create mode 100644 packages/models/src/tokens/oauth-client-local.model.ts create mode 100644 packages/models/src/users/index.ts create mode 100644 packages/models/src/users/registration/index.ts create mode 100644 packages/models/src/users/registration/user-register.model.ts create mode 100644 packages/models/src/users/registration/user-registration-request.model.ts create mode 100644 packages/models/src/users/registration/user-registration-state.model.ts create mode 100644 packages/models/src/users/registration/user-registration-update-state.model.ts create mode 100644 packages/models/src/users/registration/user-registration.model.ts create mode 100644 packages/models/src/users/two-factor-enable-result.model.ts create mode 100644 packages/models/src/users/user-create-result.model.ts create mode 100644 packages/models/src/users/user-create.model.ts create mode 100644 packages/models/src/users/user-flag.model.ts create mode 100644 packages/models/src/users/user-login.model.ts create mode 100644 packages/models/src/users/user-notification-setting.model.ts create mode 100644 packages/models/src/users/user-notification.model.ts create mode 100644 packages/models/src/users/user-refresh-token.model.ts create mode 100644 packages/models/src/users/user-right.enum.ts create mode 100644 packages/models/src/users/user-role.ts create mode 100644 packages/models/src/users/user-scoped-token.ts create mode 100644 packages/models/src/users/user-update-me.model.ts create mode 100644 packages/models/src/users/user-update.model.ts create mode 100644 packages/models/src/users/user-video-quota.model.ts create mode 100644 packages/models/src/users/user.model.ts create mode 100644 packages/models/src/videos/blacklist/index.ts create mode 100644 packages/models/src/videos/blacklist/video-blacklist-create.model.ts create mode 100644 packages/models/src/videos/blacklist/video-blacklist-update.model.ts create mode 100644 packages/models/src/videos/blacklist/video-blacklist.model.ts create mode 100644 packages/models/src/videos/caption/index.ts create mode 100644 packages/models/src/videos/caption/video-caption-update.model.ts create mode 100644 packages/models/src/videos/caption/video-caption.model.ts create mode 100644 packages/models/src/videos/change-ownership/index.ts create mode 100644 packages/models/src/videos/change-ownership/video-change-ownership-accept.model.ts create mode 100644 packages/models/src/videos/change-ownership/video-change-ownership-create.model.ts create mode 100644 packages/models/src/videos/change-ownership/video-change-ownership.model.ts create mode 100644 packages/models/src/videos/channel-sync/index.ts create mode 100644 packages/models/src/videos/channel-sync/video-channel-sync-create.model.ts create mode 100644 packages/models/src/videos/channel-sync/video-channel-sync-state.enum.ts create mode 100644 packages/models/src/videos/channel-sync/video-channel-sync.model.ts create mode 100644 packages/models/src/videos/channel/index.ts create mode 100644 packages/models/src/videos/channel/video-channel-create-result.model.ts create mode 100644 packages/models/src/videos/channel/video-channel-create.model.ts create mode 100644 packages/models/src/videos/channel/video-channel-update.model.ts create mode 100644 packages/models/src/videos/channel/video-channel.model.ts create mode 100644 packages/models/src/videos/comment/index.ts create mode 100644 packages/models/src/videos/comment/video-comment-create.model.ts create mode 100644 packages/models/src/videos/comment/video-comment.model.ts create mode 100644 packages/models/src/videos/file/index.ts create mode 100644 packages/models/src/videos/file/video-file-metadata.model.ts create mode 100644 packages/models/src/videos/file/video-file.model.ts create mode 100644 packages/models/src/videos/file/video-resolution.enum.ts create mode 100644 packages/models/src/videos/import/index.ts create mode 100644 packages/models/src/videos/import/video-import-create.model.ts create mode 100644 packages/models/src/videos/import/video-import-state.enum.ts create mode 100644 packages/models/src/videos/import/video-import.model.ts create mode 100644 packages/models/src/videos/import/videos-import-in-channel-create.model.ts create mode 100644 packages/models/src/videos/index.ts create mode 100644 packages/models/src/videos/live/index.ts create mode 100644 packages/models/src/videos/live/live-video-create.model.ts create mode 100644 packages/models/src/videos/live/live-video-error.enum.ts create mode 100644 packages/models/src/videos/live/live-video-event-payload.model.ts create mode 100644 packages/models/src/videos/live/live-video-event.type.ts create mode 100644 packages/models/src/videos/live/live-video-latency-mode.enum.ts create mode 100644 packages/models/src/videos/live/live-video-session.model.ts create mode 100644 packages/models/src/videos/live/live-video-update.model.ts create mode 100644 packages/models/src/videos/live/live-video.model.ts create mode 100644 packages/models/src/videos/nsfw-policy.type.ts create mode 100644 packages/models/src/videos/playlist/index.ts create mode 100644 packages/models/src/videos/playlist/video-exist-in-playlist.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-create-result.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-create.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-element-create-result.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-element-create.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-element-update.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-element.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-privacy.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-reorder.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-type.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist-update.model.ts create mode 100644 packages/models/src/videos/playlist/video-playlist.model.ts create mode 100644 packages/models/src/videos/rate/account-video-rate.model.ts create mode 100644 packages/models/src/videos/rate/index.ts create mode 100644 packages/models/src/videos/rate/user-video-rate-update.model.ts create mode 100644 packages/models/src/videos/rate/user-video-rate.model.ts create mode 100644 packages/models/src/videos/rate/user-video-rate.type.ts create mode 100644 packages/models/src/videos/stats/index.ts create mode 100644 packages/models/src/videos/stats/video-stats-overall-query.model.ts create mode 100644 packages/models/src/videos/stats/video-stats-overall.model.ts create mode 100644 packages/models/src/videos/stats/video-stats-retention.model.ts create mode 100644 packages/models/src/videos/stats/video-stats-timeserie-metric.type.ts create mode 100644 packages/models/src/videos/stats/video-stats-timeserie-query.model.ts create mode 100644 packages/models/src/videos/stats/video-stats-timeserie.model.ts create mode 100644 packages/models/src/videos/storyboard.model.ts create mode 100644 packages/models/src/videos/studio/index.ts create mode 100644 packages/models/src/videos/studio/video-studio-create-edit.model.ts create mode 100644 packages/models/src/videos/thumbnail.type.ts create mode 100644 packages/models/src/videos/transcoding/index.ts create mode 100644 packages/models/src/videos/transcoding/video-transcoding-create.model.ts create mode 100644 packages/models/src/videos/transcoding/video-transcoding-fps.model.ts create mode 100644 packages/models/src/videos/transcoding/video-transcoding.model.ts create mode 100644 packages/models/src/videos/video-constant.model.ts create mode 100644 packages/models/src/videos/video-create-result.model.ts create mode 100644 packages/models/src/videos/video-create.model.ts create mode 100644 packages/models/src/videos/video-include.enum.ts create mode 100644 packages/models/src/videos/video-password.model.ts create mode 100644 packages/models/src/videos/video-privacy.enum.ts create mode 100644 packages/models/src/videos/video-rate.type.ts create mode 100644 packages/models/src/videos/video-schedule-update.model.ts create mode 100644 packages/models/src/videos/video-sort-field.type.ts create mode 100644 packages/models/src/videos/video-source.model.ts create mode 100644 packages/models/src/videos/video-state.enum.ts create mode 100644 packages/models/src/videos/video-storage.enum.ts create mode 100644 packages/models/src/videos/video-streaming-playlist.model.ts create mode 100644 packages/models/src/videos/video-streaming-playlist.type.ts create mode 100644 packages/models/src/videos/video-token.model.ts create mode 100644 packages/models/src/videos/video-update.model.ts create mode 100644 packages/models/src/videos/video-view.model.ts create mode 100644 packages/models/src/videos/video.model.ts create mode 100644 packages/models/tsconfig.json create mode 100644 packages/models/tsconfig.types.json create mode 100644 packages/node-utils/package.json create mode 100644 packages/node-utils/src/crypto.ts create mode 100644 packages/node-utils/src/env.ts create mode 100644 packages/node-utils/src/file.ts create mode 100644 packages/node-utils/src/index.ts create mode 100644 packages/node-utils/src/path.ts create mode 100644 packages/node-utils/src/uuid.ts create mode 100644 packages/node-utils/tsconfig.json delete mode 100644 packages/peertube-runner/.gitignore delete mode 100644 packages/peertube-runner/.npmignore delete mode 100644 packages/peertube-runner/README.md delete mode 100644 packages/peertube-runner/package.json delete mode 100644 packages/peertube-runner/peertube-runner.ts delete mode 100644 packages/peertube-runner/register/index.ts delete mode 100644 packages/peertube-runner/register/register.ts delete mode 100644 packages/peertube-runner/server/index.ts delete mode 100644 packages/peertube-runner/server/process/index.ts delete mode 100644 packages/peertube-runner/server/process/process.ts delete mode 100644 packages/peertube-runner/server/process/shared/common.ts delete mode 100644 packages/peertube-runner/server/process/shared/index.ts delete mode 100644 packages/peertube-runner/server/process/shared/process-live.ts delete mode 100644 packages/peertube-runner/server/process/shared/process-studio.ts delete mode 100644 packages/peertube-runner/server/process/shared/process-vod.ts delete mode 100644 packages/peertube-runner/server/process/shared/transcoding-logger.ts delete mode 100644 packages/peertube-runner/server/server.ts delete mode 100644 packages/peertube-runner/server/shared/index.ts delete mode 100644 packages/peertube-runner/server/shared/supported-job.ts delete mode 100644 packages/peertube-runner/shared/config-manager.ts delete mode 100644 packages/peertube-runner/shared/http.ts delete mode 100644 packages/peertube-runner/shared/index.ts delete mode 100644 packages/peertube-runner/shared/ipc/index.ts delete mode 100644 packages/peertube-runner/shared/ipc/ipc-client.ts delete mode 100644 packages/peertube-runner/shared/ipc/ipc-server.ts delete mode 100644 packages/peertube-runner/shared/ipc/shared/index.ts delete mode 100644 packages/peertube-runner/shared/ipc/shared/ipc-request.model.ts delete mode 100644 packages/peertube-runner/shared/ipc/shared/ipc-response.model.ts delete mode 100644 packages/peertube-runner/shared/logger.ts delete mode 100644 packages/peertube-runner/tsconfig.json delete mode 100644 packages/peertube-runner/yarn.lock create mode 100644 packages/server-commands/package.json create mode 100644 packages/server-commands/src/bulk/bulk-command.ts create mode 100644 packages/server-commands/src/bulk/index.ts create mode 100644 packages/server-commands/src/cli/cli-command.ts create mode 100644 packages/server-commands/src/cli/index.ts create mode 100644 packages/server-commands/src/custom-pages/custom-pages-command.ts create mode 100644 packages/server-commands/src/custom-pages/index.ts create mode 100644 packages/server-commands/src/feeds/feeds-command.ts create mode 100644 packages/server-commands/src/feeds/index.ts create mode 100644 packages/server-commands/src/index.ts create mode 100644 packages/server-commands/src/logs/index.ts create mode 100644 packages/server-commands/src/logs/logs-command.ts create mode 100644 packages/server-commands/src/moderation/abuses-command.ts create mode 100644 packages/server-commands/src/moderation/index.ts create mode 100644 packages/server-commands/src/overviews/index.ts create mode 100644 packages/server-commands/src/overviews/overviews-command.ts create mode 100644 packages/server-commands/src/requests/index.ts create mode 100644 packages/server-commands/src/requests/requests.ts create mode 100644 packages/server-commands/src/runners/index.ts create mode 100644 packages/server-commands/src/runners/runner-jobs-command.ts create mode 100644 packages/server-commands/src/runners/runner-registration-tokens-command.ts create mode 100644 packages/server-commands/src/runners/runners-command.ts create mode 100644 packages/server-commands/src/search/index.ts create mode 100644 packages/server-commands/src/search/search-command.ts create mode 100644 packages/server-commands/src/server/config-command.ts create mode 100644 packages/server-commands/src/server/contact-form-command.ts create mode 100644 packages/server-commands/src/server/debug-command.ts create mode 100644 packages/server-commands/src/server/follows-command.ts create mode 100644 packages/server-commands/src/server/follows.ts create mode 100644 packages/server-commands/src/server/index.ts create mode 100644 packages/server-commands/src/server/jobs-command.ts create mode 100644 packages/server-commands/src/server/jobs.ts create mode 100644 packages/server-commands/src/server/metrics-command.ts create mode 100644 packages/server-commands/src/server/object-storage-command.ts create mode 100644 packages/server-commands/src/server/plugins-command.ts create mode 100644 packages/server-commands/src/server/redundancy-command.ts create mode 100644 packages/server-commands/src/server/server.ts create mode 100644 packages/server-commands/src/server/servers-command.ts create mode 100644 packages/server-commands/src/server/servers.ts create mode 100644 packages/server-commands/src/server/stats-command.ts create mode 100644 packages/server-commands/src/shared/abstract-command.ts create mode 100644 packages/server-commands/src/shared/index.ts create mode 100644 packages/server-commands/src/socket/index.ts create mode 100644 packages/server-commands/src/socket/socket-io-command.ts create mode 100644 packages/server-commands/src/users/accounts-command.ts create mode 100644 packages/server-commands/src/users/accounts.ts create mode 100644 packages/server-commands/src/users/blocklist-command.ts create mode 100644 packages/server-commands/src/users/index.ts create mode 100644 packages/server-commands/src/users/login-command.ts create mode 100644 packages/server-commands/src/users/login.ts create mode 100644 packages/server-commands/src/users/notifications-command.ts create mode 100644 packages/server-commands/src/users/registrations-command.ts create mode 100644 packages/server-commands/src/users/subscriptions-command.ts create mode 100644 packages/server-commands/src/users/two-factor-command.ts create mode 100644 packages/server-commands/src/users/users-command.ts create mode 100644 packages/server-commands/src/videos/blacklist-command.ts create mode 100644 packages/server-commands/src/videos/captions-command.ts create mode 100644 packages/server-commands/src/videos/change-ownership-command.ts create mode 100644 packages/server-commands/src/videos/channel-syncs-command.ts create mode 100644 packages/server-commands/src/videos/channels-command.ts create mode 100644 packages/server-commands/src/videos/channels.ts create mode 100644 packages/server-commands/src/videos/comments-command.ts create mode 100644 packages/server-commands/src/videos/history-command.ts create mode 100644 packages/server-commands/src/videos/imports-command.ts create mode 100644 packages/server-commands/src/videos/index.ts create mode 100644 packages/server-commands/src/videos/live-command.ts create mode 100644 packages/server-commands/src/videos/live.ts create mode 100644 packages/server-commands/src/videos/playlists-command.ts create mode 100644 packages/server-commands/src/videos/services-command.ts create mode 100644 packages/server-commands/src/videos/storyboard-command.ts create mode 100644 packages/server-commands/src/videos/streaming-playlists-command.ts create mode 100644 packages/server-commands/src/videos/video-passwords-command.ts create mode 100644 packages/server-commands/src/videos/video-stats-command.ts create mode 100644 packages/server-commands/src/videos/video-studio-command.ts create mode 100644 packages/server-commands/src/videos/video-token-command.ts create mode 100644 packages/server-commands/src/videos/videos-command.ts create mode 100644 packages/server-commands/src/videos/views-command.ts create mode 100644 packages/server-commands/tsconfig.json create mode 100644 packages/tests/fixtures/60fps_720p_small.mp4 create mode 100644 packages/tests/fixtures/ap-json/mastodon/bad-body-http-signature.json create mode 100644 packages/tests/fixtures/ap-json/mastodon/bad-http-signature.json create mode 100644 packages/tests/fixtures/ap-json/mastodon/bad-public-key.json create mode 100644 packages/tests/fixtures/ap-json/mastodon/create-bad-signature.json create mode 100644 packages/tests/fixtures/ap-json/mastodon/create.json create mode 100644 packages/tests/fixtures/ap-json/mastodon/http-signature.json create mode 100644 packages/tests/fixtures/ap-json/mastodon/public-key.json create mode 100644 packages/tests/fixtures/ap-json/peertube/announce-without-context.json create mode 100644 packages/tests/fixtures/ap-json/peertube/invalid-keys.json create mode 100644 packages/tests/fixtures/ap-json/peertube/keys.json create mode 100644 packages/tests/fixtures/avatar-big.png create mode 100644 packages/tests/fixtures/avatar-resized-120x120.gif create mode 100644 packages/tests/fixtures/avatar-resized-120x120.png create mode 100644 packages/tests/fixtures/avatar-resized-48x48.gif create mode 100644 packages/tests/fixtures/avatar-resized-48x48.png create mode 100644 packages/tests/fixtures/avatar.gif create mode 100644 packages/tests/fixtures/avatar.png create mode 100644 packages/tests/fixtures/avatar2-resized-120x120.png create mode 100644 packages/tests/fixtures/avatar2-resized-48x48.png create mode 100644 packages/tests/fixtures/avatar2.png create mode 100644 packages/tests/fixtures/banner-resized.jpg create mode 100644 packages/tests/fixtures/banner.jpg create mode 100644 packages/tests/fixtures/custom-preview-big.png create mode 100644 packages/tests/fixtures/custom-preview.jpg create mode 100644 packages/tests/fixtures/custom-thumbnail-big.jpg create mode 100644 packages/tests/fixtures/custom-thumbnail.jpg create mode 100644 packages/tests/fixtures/custom-thumbnail.png create mode 100644 packages/tests/fixtures/exif.jpg create mode 100644 packages/tests/fixtures/exif.png create mode 100644 packages/tests/fixtures/live/0-000067.ts create mode 100644 packages/tests/fixtures/live/0-000068.ts create mode 100644 packages/tests/fixtures/live/0-000069.ts create mode 100644 packages/tests/fixtures/live/0-000070.ts create mode 100644 packages/tests/fixtures/live/0.m3u8 create mode 100644 packages/tests/fixtures/live/1-000067.ts create mode 100644 packages/tests/fixtures/live/1-000068.ts create mode 100644 packages/tests/fixtures/live/1-000069.ts create mode 100644 packages/tests/fixtures/live/1-000070.ts create mode 100644 packages/tests/fixtures/live/1.m3u8 create mode 100644 packages/tests/fixtures/live/master.m3u8 create mode 100644 packages/tests/fixtures/low-bitrate.mp4 create mode 100644 packages/tests/fixtures/peertube-plugin-test-broken/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-broken/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-external-auth-one/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-external-auth-one/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-external-auth-three/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-external-auth-three/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-external-auth-two/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-external-auth-two/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-filter-translations/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-filter-translations/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-five/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-five/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-four/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-four/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-id-pass-auth-one/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-id-pass-auth-one/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-id-pass-auth-three/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-id-pass-auth-three/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-id-pass-auth-two/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-native/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-native/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-six/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-six/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-transcoding-one/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-transcoding-one/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-transcoding-two/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-transcoding-two/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-unloading/lib.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-unloading/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-unloading/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-video-constants/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-video-constants/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test-websocket/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test-websocket/package.json create mode 100644 packages/tests/fixtures/peertube-plugin-test/languages/fr.json create mode 100644 packages/tests/fixtures/peertube-plugin-test/main.js create mode 100644 packages/tests/fixtures/peertube-plugin-test/package.json create mode 100644 packages/tests/fixtures/rtmps.cert create mode 100644 packages/tests/fixtures/rtmps.key create mode 100644 packages/tests/fixtures/sample.ogg create mode 100644 packages/tests/fixtures/subtitle-bad.txt create mode 100644 packages/tests/fixtures/subtitle-good.srt create mode 100644 packages/tests/fixtures/subtitle-good1.vtt create mode 100644 packages/tests/fixtures/subtitle-good2.vtt create mode 100644 packages/tests/fixtures/thumbnail-playlist.jpg create mode 100644 packages/tests/fixtures/video-720p.torrent create mode 100644 packages/tests/fixtures/video_import_preview.jpg create mode 100644 packages/tests/fixtures/video_import_preview_yt_dlp.jpg create mode 100644 packages/tests/fixtures/video_import_thumbnail.jpg create mode 100644 packages/tests/fixtures/video_import_thumbnail_yt_dlp.jpg create mode 100644 packages/tests/fixtures/video_short.avi create mode 100644 packages/tests/fixtures/video_short.mkv create mode 100644 packages/tests/fixtures/video_short.mp4 create mode 100644 packages/tests/fixtures/video_short.mp4.jpg create mode 100644 packages/tests/fixtures/video_short.ogv create mode 100644 packages/tests/fixtures/video_short.ogv.jpg create mode 100644 packages/tests/fixtures/video_short.webm create mode 100644 packages/tests/fixtures/video_short.webm.jpg create mode 100644 packages/tests/fixtures/video_short1-preview.webm.jpg create mode 100644 packages/tests/fixtures/video_short1.webm create mode 100644 packages/tests/fixtures/video_short1.webm.jpg create mode 100644 packages/tests/fixtures/video_short2.webm create mode 100644 packages/tests/fixtures/video_short2.webm.jpg create mode 100644 packages/tests/fixtures/video_short3.webm create mode 100644 packages/tests/fixtures/video_short3.webm.jpg create mode 100644 packages/tests/fixtures/video_short_0p.mp4 create mode 100644 packages/tests/fixtures/video_short_144p.m3u8 create mode 100644 packages/tests/fixtures/video_short_144p.mp4 create mode 100644 packages/tests/fixtures/video_short_240p.m3u8 create mode 100644 packages/tests/fixtures/video_short_240p.mp4 create mode 100644 packages/tests/fixtures/video_short_360p.m3u8 create mode 100644 packages/tests/fixtures/video_short_360p.mp4 create mode 100644 packages/tests/fixtures/video_short_480.webm create mode 100644 packages/tests/fixtures/video_short_480p.m3u8 create mode 100644 packages/tests/fixtures/video_short_480p.mp4 create mode 100644 packages/tests/fixtures/video_short_4k.mp4 create mode 100644 packages/tests/fixtures/video_short_720p.m3u8 create mode 100644 packages/tests/fixtures/video_short_720p.mp4 create mode 100644 packages/tests/fixtures/video_short_fake.webm create mode 100644 packages/tests/fixtures/video_short_mp3_256k.mp4 create mode 100644 packages/tests/fixtures/video_short_no_audio.mp4 create mode 100644 packages/tests/fixtures/video_very_long_10p.mp4 create mode 100644 packages/tests/fixtures/video_very_short_240p.mp4 create mode 100644 packages/tests/package.json create mode 100644 packages/tests/src/api/activitypub/cleaner.ts create mode 100644 packages/tests/src/api/activitypub/client.ts create mode 100644 packages/tests/src/api/activitypub/fetch.ts create mode 100644 packages/tests/src/api/activitypub/index.ts create mode 100644 packages/tests/src/api/activitypub/refresher.ts create mode 100644 packages/tests/src/api/activitypub/security.ts create mode 100644 packages/tests/src/api/check-params/abuses.ts create mode 100644 packages/tests/src/api/check-params/accounts.ts create mode 100644 packages/tests/src/api/check-params/blocklist.ts create mode 100644 packages/tests/src/api/check-params/bulk.ts create mode 100644 packages/tests/src/api/check-params/channel-import-videos.ts create mode 100644 packages/tests/src/api/check-params/config.ts create mode 100644 packages/tests/src/api/check-params/contact-form.ts create mode 100644 packages/tests/src/api/check-params/custom-pages.ts create mode 100644 packages/tests/src/api/check-params/debug.ts create mode 100644 packages/tests/src/api/check-params/follows.ts create mode 100644 packages/tests/src/api/check-params/index.ts create mode 100644 packages/tests/src/api/check-params/jobs.ts create mode 100644 packages/tests/src/api/check-params/live.ts create mode 100644 packages/tests/src/api/check-params/logs.ts create mode 100644 packages/tests/src/api/check-params/metrics.ts create mode 100644 packages/tests/src/api/check-params/my-user.ts create mode 100644 packages/tests/src/api/check-params/plugins.ts create mode 100644 packages/tests/src/api/check-params/redundancy.ts create mode 100644 packages/tests/src/api/check-params/registrations.ts create mode 100644 packages/tests/src/api/check-params/runners.ts create mode 100644 packages/tests/src/api/check-params/search.ts create mode 100644 packages/tests/src/api/check-params/services.ts create mode 100644 packages/tests/src/api/check-params/transcoding.ts create mode 100644 packages/tests/src/api/check-params/two-factor.ts create mode 100644 packages/tests/src/api/check-params/upload-quota.ts create mode 100644 packages/tests/src/api/check-params/user-notifications.ts create mode 100644 packages/tests/src/api/check-params/user-subscriptions.ts create mode 100644 packages/tests/src/api/check-params/users-admin.ts create mode 100644 packages/tests/src/api/check-params/users-emails.ts create mode 100644 packages/tests/src/api/check-params/video-blacklist.ts create mode 100644 packages/tests/src/api/check-params/video-captions.ts create mode 100644 packages/tests/src/api/check-params/video-channel-syncs.ts create mode 100644 packages/tests/src/api/check-params/video-channels.ts create mode 100644 packages/tests/src/api/check-params/video-comments.ts create mode 100644 packages/tests/src/api/check-params/video-files.ts create mode 100644 packages/tests/src/api/check-params/video-imports.ts create mode 100644 packages/tests/src/api/check-params/video-passwords.ts create mode 100644 packages/tests/src/api/check-params/video-playlists.ts create mode 100644 packages/tests/src/api/check-params/video-source.ts create mode 100644 packages/tests/src/api/check-params/video-storyboards.ts create mode 100644 packages/tests/src/api/check-params/video-studio.ts create mode 100644 packages/tests/src/api/check-params/video-token.ts create mode 100644 packages/tests/src/api/check-params/videos-common-filters.ts create mode 100644 packages/tests/src/api/check-params/videos-history.ts create mode 100644 packages/tests/src/api/check-params/videos-overviews.ts create mode 100644 packages/tests/src/api/check-params/videos.ts create mode 100644 packages/tests/src/api/check-params/views.ts create mode 100644 packages/tests/src/api/live/index.ts create mode 100644 packages/tests/src/api/live/live-constraints.ts create mode 100644 packages/tests/src/api/live/live-fast-restream.ts create mode 100644 packages/tests/src/api/live/live-permanent.ts create mode 100644 packages/tests/src/api/live/live-rtmps.ts create mode 100644 packages/tests/src/api/live/live-save-replay.ts create mode 100644 packages/tests/src/api/live/live-socket-messages.ts create mode 100644 packages/tests/src/api/live/live.ts create mode 100644 packages/tests/src/api/moderation/abuses.ts create mode 100644 packages/tests/src/api/moderation/blocklist-notification.ts create mode 100644 packages/tests/src/api/moderation/blocklist.ts create mode 100644 packages/tests/src/api/moderation/index.ts create mode 100644 packages/tests/src/api/moderation/video-blacklist.ts create mode 100644 packages/tests/src/api/notifications/admin-notifications.ts create mode 100644 packages/tests/src/api/notifications/comments-notifications.ts create mode 100644 packages/tests/src/api/notifications/index.ts create mode 100644 packages/tests/src/api/notifications/moderation-notifications.ts create mode 100644 packages/tests/src/api/notifications/notifications-api.ts create mode 100644 packages/tests/src/api/notifications/registrations-notifications.ts create mode 100644 packages/tests/src/api/notifications/user-notifications.ts create mode 100644 packages/tests/src/api/object-storage/index.ts create mode 100644 packages/tests/src/api/object-storage/live.ts create mode 100644 packages/tests/src/api/object-storage/video-imports.ts create mode 100644 packages/tests/src/api/object-storage/video-static-file-privacy.ts create mode 100644 packages/tests/src/api/object-storage/videos.ts create mode 100644 packages/tests/src/api/redundancy/index.ts create mode 100644 packages/tests/src/api/redundancy/manage-redundancy.ts create mode 100644 packages/tests/src/api/redundancy/redundancy-constraints.ts create mode 100644 packages/tests/src/api/redundancy/redundancy.ts create mode 100644 packages/tests/src/api/runners/index.ts create mode 100644 packages/tests/src/api/runners/runner-common.ts create mode 100644 packages/tests/src/api/runners/runner-live-transcoding.ts create mode 100644 packages/tests/src/api/runners/runner-socket.ts create mode 100644 packages/tests/src/api/runners/runner-studio-transcoding.ts create mode 100644 packages/tests/src/api/runners/runner-vod-transcoding.ts create mode 100644 packages/tests/src/api/search/index.ts create mode 100644 packages/tests/src/api/search/search-activitypub-video-channels.ts create mode 100644 packages/tests/src/api/search/search-activitypub-video-playlists.ts create mode 100644 packages/tests/src/api/search/search-activitypub-videos.ts create mode 100644 packages/tests/src/api/search/search-channels.ts create mode 100644 packages/tests/src/api/search/search-index.ts create mode 100644 packages/tests/src/api/search/search-playlists.ts create mode 100644 packages/tests/src/api/search/search-videos.ts create mode 100644 packages/tests/src/api/server/auto-follows.ts create mode 100644 packages/tests/src/api/server/bulk.ts create mode 100644 packages/tests/src/api/server/config-defaults.ts create mode 100644 packages/tests/src/api/server/config.ts create mode 100644 packages/tests/src/api/server/contact-form.ts create mode 100644 packages/tests/src/api/server/email.ts create mode 100644 packages/tests/src/api/server/follow-constraints.ts create mode 100644 packages/tests/src/api/server/follows-moderation.ts create mode 100644 packages/tests/src/api/server/follows.ts create mode 100644 packages/tests/src/api/server/handle-down.ts create mode 100644 packages/tests/src/api/server/homepage.ts create mode 100644 packages/tests/src/api/server/index.ts create mode 100644 packages/tests/src/api/server/jobs.ts create mode 100644 packages/tests/src/api/server/logs.ts create mode 100644 packages/tests/src/api/server/no-client.ts create mode 100644 packages/tests/src/api/server/open-telemetry.ts create mode 100644 packages/tests/src/api/server/plugins.ts create mode 100644 packages/tests/src/api/server/proxy.ts create mode 100644 packages/tests/src/api/server/reverse-proxy.ts create mode 100644 packages/tests/src/api/server/services.ts create mode 100644 packages/tests/src/api/server/slow-follows.ts create mode 100644 packages/tests/src/api/server/stats.ts create mode 100644 packages/tests/src/api/server/tracker.ts create mode 100644 packages/tests/src/api/transcoding/audio-only.ts create mode 100644 packages/tests/src/api/transcoding/create-transcoding.ts create mode 100644 packages/tests/src/api/transcoding/hls.ts create mode 100644 packages/tests/src/api/transcoding/index.ts create mode 100644 packages/tests/src/api/transcoding/transcoder.ts create mode 100644 packages/tests/src/api/transcoding/update-while-transcoding.ts create mode 100644 packages/tests/src/api/transcoding/video-studio.ts create mode 100644 packages/tests/src/api/users/index.ts create mode 100644 packages/tests/src/api/users/oauth.ts create mode 100644 packages/tests/src/api/users/registrations.ts create mode 100644 packages/tests/src/api/users/two-factor.ts create mode 100644 packages/tests/src/api/users/user-subscriptions.ts create mode 100644 packages/tests/src/api/users/user-videos.ts create mode 100644 packages/tests/src/api/users/users-email-verification.ts create mode 100644 packages/tests/src/api/users/users-multiple-servers.ts create mode 100644 packages/tests/src/api/users/users.ts create mode 100644 packages/tests/src/api/videos/channel-import-videos.ts create mode 100644 packages/tests/src/api/videos/index.ts create mode 100644 packages/tests/src/api/videos/multiple-servers.ts create mode 100644 packages/tests/src/api/videos/resumable-upload.ts create mode 100644 packages/tests/src/api/videos/single-server.ts create mode 100644 packages/tests/src/api/videos/video-captions.ts create mode 100644 packages/tests/src/api/videos/video-change-ownership.ts create mode 100644 packages/tests/src/api/videos/video-channel-syncs.ts create mode 100644 packages/tests/src/api/videos/video-channels.ts create mode 100644 packages/tests/src/api/videos/video-comments.ts create mode 100644 packages/tests/src/api/videos/video-description.ts create mode 100644 packages/tests/src/api/videos/video-files.ts create mode 100644 packages/tests/src/api/videos/video-imports.ts create mode 100644 packages/tests/src/api/videos/video-nsfw.ts create mode 100644 packages/tests/src/api/videos/video-passwords.ts create mode 100644 packages/tests/src/api/videos/video-playlist-thumbnails.ts create mode 100644 packages/tests/src/api/videos/video-playlists.ts create mode 100644 packages/tests/src/api/videos/video-privacy.ts create mode 100644 packages/tests/src/api/videos/video-schedule-update.ts create mode 100644 packages/tests/src/api/videos/video-source.ts create mode 100644 packages/tests/src/api/videos/video-static-file-privacy.ts create mode 100644 packages/tests/src/api/videos/video-storyboard.ts create mode 100644 packages/tests/src/api/videos/videos-common-filters.ts create mode 100644 packages/tests/src/api/videos/videos-history.ts create mode 100644 packages/tests/src/api/videos/videos-overview.ts create mode 100644 packages/tests/src/api/views/index.ts create mode 100644 packages/tests/src/api/views/video-views-counter.ts create mode 100644 packages/tests/src/api/views/video-views-overall-stats.ts create mode 100644 packages/tests/src/api/views/video-views-retention-stats.ts create mode 100644 packages/tests/src/api/views/video-views-timeserie-stats.ts create mode 100644 packages/tests/src/api/views/videos-views-cleaner.ts create mode 100644 packages/tests/src/cli/create-generate-storyboard-job.ts create mode 100644 packages/tests/src/cli/create-import-video-file-job.ts create mode 100644 packages/tests/src/cli/create-move-video-storage-job.ts create mode 100644 packages/tests/src/cli/index.ts create mode 100644 packages/tests/src/cli/peertube.ts create mode 100644 packages/tests/src/cli/plugins.ts create mode 100644 packages/tests/src/cli/prune-storage.ts create mode 100644 packages/tests/src/cli/regenerate-thumbnails.ts create mode 100644 packages/tests/src/cli/reset-password.ts create mode 100644 packages/tests/src/cli/update-host.ts create mode 100644 packages/tests/src/client.ts create mode 100644 packages/tests/src/external-plugins/akismet.ts create mode 100644 packages/tests/src/external-plugins/auth-ldap.ts create mode 100644 packages/tests/src/external-plugins/auto-block-videos.ts create mode 100644 packages/tests/src/external-plugins/auto-mute.ts create mode 100644 packages/tests/src/external-plugins/index.ts create mode 100644 packages/tests/src/feeds/feeds.ts create mode 100644 packages/tests/src/feeds/index.ts create mode 100644 packages/tests/src/misc-endpoints.ts create mode 100644 packages/tests/src/peertube-runner/client-cli.ts create mode 100644 packages/tests/src/peertube-runner/index.ts create mode 100644 packages/tests/src/peertube-runner/live-transcoding.ts create mode 100644 packages/tests/src/peertube-runner/studio-transcoding.ts create mode 100644 packages/tests/src/peertube-runner/vod-transcoding.ts create mode 100644 packages/tests/src/plugins/action-hooks.ts create mode 100644 packages/tests/src/plugins/external-auth.ts create mode 100644 packages/tests/src/plugins/filter-hooks.ts create mode 100644 packages/tests/src/plugins/html-injection.ts create mode 100644 packages/tests/src/plugins/id-and-pass-auth.ts create mode 100644 packages/tests/src/plugins/index.ts create mode 100644 packages/tests/src/plugins/plugin-helpers.ts create mode 100644 packages/tests/src/plugins/plugin-router.ts create mode 100644 packages/tests/src/plugins/plugin-storage.ts create mode 100644 packages/tests/src/plugins/plugin-transcoding.ts create mode 100644 packages/tests/src/plugins/plugin-unloading.ts create mode 100644 packages/tests/src/plugins/plugin-websocket.ts create mode 100644 packages/tests/src/plugins/translations.ts create mode 100644 packages/tests/src/plugins/video-constants.ts create mode 100644 packages/tests/src/server-helpers/activitypub.ts create mode 100644 packages/tests/src/server-helpers/core-utils.ts create mode 100644 packages/tests/src/server-helpers/crypto.ts create mode 100644 packages/tests/src/server-helpers/dns.ts create mode 100644 packages/tests/src/server-helpers/image.ts create mode 100644 packages/tests/src/server-helpers/index.ts create mode 100644 packages/tests/src/server-helpers/markdown.ts create mode 100644 packages/tests/src/server-helpers/mentions.ts create mode 100644 packages/tests/src/server-helpers/request.ts create mode 100644 packages/tests/src/server-helpers/validator.ts create mode 100644 packages/tests/src/server-helpers/version.ts create mode 100644 packages/tests/src/server-lib/index.ts create mode 100644 packages/tests/src/server-lib/video-constant-registry-factory.ts create mode 100644 packages/tests/src/shared/actors.ts create mode 100644 packages/tests/src/shared/captions.ts create mode 100644 packages/tests/src/shared/checks.ts create mode 100644 packages/tests/src/shared/directories.ts create mode 100644 packages/tests/src/shared/generate.ts create mode 100644 packages/tests/src/shared/live.ts create mode 100644 packages/tests/src/shared/mock-servers/index.ts create mode 100644 packages/tests/src/shared/mock-servers/mock-429.ts create mode 100644 packages/tests/src/shared/mock-servers/mock-email.ts create mode 100644 packages/tests/src/shared/mock-servers/mock-http.ts create mode 100644 packages/tests/src/shared/mock-servers/mock-instances-index.ts create mode 100644 packages/tests/src/shared/mock-servers/mock-joinpeertube-versions.ts create mode 100644 packages/tests/src/shared/mock-servers/mock-object-storage.ts create mode 100644 packages/tests/src/shared/mock-servers/mock-plugin-blocklist.ts create mode 100644 packages/tests/src/shared/mock-servers/mock-proxy.ts create mode 100644 packages/tests/src/shared/mock-servers/shared.ts create mode 100644 packages/tests/src/shared/notifications.ts create mode 100644 packages/tests/src/shared/peertube-runner-process.ts create mode 100644 packages/tests/src/shared/plugins.ts create mode 100644 packages/tests/src/shared/requests.ts create mode 100644 packages/tests/src/shared/sql-command.ts create mode 100644 packages/tests/src/shared/streaming-playlists.ts create mode 100644 packages/tests/src/shared/tests.ts create mode 100644 packages/tests/src/shared/tracker.ts create mode 100644 packages/tests/src/shared/video-playlists.ts create mode 100644 packages/tests/src/shared/videos.ts create mode 100644 packages/tests/src/shared/views.ts create mode 100644 packages/tests/src/shared/webtorrent.ts create mode 100644 packages/tests/tsconfig.json create mode 100644 packages/types-generator/README.md create mode 100644 packages/types-generator/generate-package.ts create mode 100644 packages/types-generator/package.json create mode 100644 packages/types-generator/src/client/index.ts create mode 100644 packages/types-generator/src/client/tsconfig.types.json create mode 100644 packages/types-generator/src/index.ts create mode 100644 packages/types-generator/tests/test.ts create mode 100644 packages/types-generator/tsconfig.dist.json create mode 100644 packages/types-generator/tsconfig.json create mode 100644 packages/types-generator/tsconfig.types.json delete mode 100644 packages/types/README.md delete mode 100644 packages/types/generate-package.ts delete mode 100644 packages/types/src/client/index.ts delete mode 100644 packages/types/src/client/tsconfig.json delete mode 100644 packages/types/src/index.ts delete mode 100644 packages/types/tests/test.ts delete mode 100644 packages/types/tsconfig.dist.json delete mode 100644 packages/types/tsconfig.json create mode 100644 packages/typescript-utils/package.json create mode 100644 packages/typescript-utils/src/index.ts create mode 100644 packages/typescript-utils/src/types.ts create mode 100644 packages/typescript-utils/tsconfig.json create mode 100644 packages/typescript-utils/tsconfig.types.json (limited to 'packages') diff --git a/packages/core-utils/package.json b/packages/core-utils/package.json new file mode 100644 index 000000000..d3bf18335 --- /dev/null +++ b/packages/core-utils/package.json @@ -0,0 +1,19 @@ +{ + "name": "@peertube/peertube-core-utils", + "private": true, + "version": "0.0.0", + "main": "dist/index.js", + "files": [ "dist" ], + "exports": { + "types": "./dist/index.d.ts", + "peertube:tsx": "./src/index.ts", + "default": "./dist/index.js" + }, + "type": "module", + "devDependencies": {}, + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "dependencies": {} +} diff --git a/packages/core-utils/src/abuse/abuse-predefined-reasons.ts b/packages/core-utils/src/abuse/abuse-predefined-reasons.ts new file mode 100644 index 000000000..68534a1e0 --- /dev/null +++ b/packages/core-utils/src/abuse/abuse-predefined-reasons.ts @@ -0,0 +1,14 @@ +import { AbusePredefinedReasons, AbusePredefinedReasonsString, AbusePredefinedReasonsType } from '@peertube/peertube-models' + +export const abusePredefinedReasonsMap: { + [key in AbusePredefinedReasonsString]: AbusePredefinedReasonsType +} = { + violentOrRepulsive: AbusePredefinedReasons.VIOLENT_OR_REPULSIVE, + hatefulOrAbusive: AbusePredefinedReasons.HATEFUL_OR_ABUSIVE, + spamOrMisleading: AbusePredefinedReasons.SPAM_OR_MISLEADING, + privacy: AbusePredefinedReasons.PRIVACY, + rights: AbusePredefinedReasons.RIGHTS, + serverRules: AbusePredefinedReasons.SERVER_RULES, + thumbnails: AbusePredefinedReasons.THUMBNAILS, + captions: AbusePredefinedReasons.CAPTIONS +} as const diff --git a/packages/core-utils/src/abuse/index.ts b/packages/core-utils/src/abuse/index.ts new file mode 100644 index 000000000..b79b86155 --- /dev/null +++ b/packages/core-utils/src/abuse/index.ts @@ -0,0 +1 @@ +export * from './abuse-predefined-reasons.js' diff --git a/packages/core-utils/src/common/array.ts b/packages/core-utils/src/common/array.ts new file mode 100644 index 000000000..878ed1ffe --- /dev/null +++ b/packages/core-utils/src/common/array.ts @@ -0,0 +1,41 @@ +function findCommonElement (array1: T[], array2: T[]) { + for (const a of array1) { + for (const b of array2) { + if (a === b) return a + } + } + + return null +} + +// Avoid conflict with other toArray() functions +function arrayify (element: T | T[]) { + if (Array.isArray(element)) return element + + return [ element ] +} + +// Avoid conflict with other uniq() functions +function uniqify (elements: T[]) { + return Array.from(new Set(elements)) +} + +// Thanks: https://stackoverflow.com/a/12646864 +function shuffle (elements: T[]) { + const shuffled = [ ...elements ] + + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + + [ shuffled[i], shuffled[j] ] = [ shuffled[j], shuffled[i] ] + } + + return shuffled +} + +export { + uniqify, + findCommonElement, + shuffle, + arrayify +} diff --git a/packages/core-utils/src/common/date.ts b/packages/core-utils/src/common/date.ts new file mode 100644 index 000000000..f0684ff86 --- /dev/null +++ b/packages/core-utils/src/common/date.ts @@ -0,0 +1,114 @@ +function isToday (d: Date) { + const today = new Date() + + return areDatesEqual(d, today) +} + +function isYesterday (d: Date) { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + + return areDatesEqual(d, yesterday) +} + +function isThisWeek (d: Date) { + const minDateOfThisWeek = new Date() + minDateOfThisWeek.setHours(0, 0, 0) + + // getDay() -> Sunday - Saturday : 0 - 6 + // We want to start our week on Monday + let dayOfWeek = minDateOfThisWeek.getDay() - 1 + if (dayOfWeek < 0) dayOfWeek = 6 // Sunday + + minDateOfThisWeek.setDate(minDateOfThisWeek.getDate() - dayOfWeek) + + return d >= minDateOfThisWeek +} + +function isThisMonth (d: Date) { + const thisMonth = new Date().getMonth() + + return d.getMonth() === thisMonth +} + +function isLastMonth (d: Date) { + const now = new Date() + + return getDaysDifferences(now, d) <= 30 +} + +function isLastWeek (d: Date) { + const now = new Date() + + return getDaysDifferences(now, d) <= 7 +} + +// --------------------------------------------------------------------------- + +function timeToInt (time: number | string) { + if (!time) return 0 + if (typeof time === 'number') return time + + const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/ + const matches = time.match(reg) + + if (!matches) return 0 + + const hours = parseInt(matches[2] || '0', 10) + const minutes = parseInt(matches[4] || '0', 10) + const seconds = parseInt(matches[6] || '0', 10) + + return hours * 3600 + minutes * 60 + seconds +} + +function secondsToTime (seconds: number, full = false, symbol?: string) { + let time = '' + + if (seconds === 0 && !full) return '0s' + + const hourSymbol = (symbol || 'h') + const minuteSymbol = (symbol || 'm') + const secondsSymbol = full ? '' : 's' + + const hours = Math.floor(seconds / 3600) + if (hours >= 1) time = hours + hourSymbol + else if (full) time = '0' + hourSymbol + + seconds %= 3600 + const minutes = Math.floor(seconds / 60) + if (minutes >= 1 && minutes < 10 && full) time += '0' + minutes + minuteSymbol + else if (minutes >= 1) time += minutes + minuteSymbol + else if (full) time += '00' + minuteSymbol + + seconds %= 60 + if (seconds >= 1 && seconds < 10 && full) time += '0' + seconds + secondsSymbol + else if (seconds >= 1) time += seconds + secondsSymbol + else if (full) time += '00' + + return time +} + +// --------------------------------------------------------------------------- + +export { + isYesterday, + isThisWeek, + isThisMonth, + isToday, + isLastMonth, + isLastWeek, + timeToInt, + secondsToTime +} + +// --------------------------------------------------------------------------- + +function areDatesEqual (d1: Date, d2: Date) { + return d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate() +} + +function getDaysDifferences (d1: Date, d2: Date) { + return (d1.getTime() - d2.getTime()) / (86400000) +} diff --git a/packages/core-utils/src/common/index.ts b/packages/core-utils/src/common/index.ts new file mode 100644 index 000000000..d7d8599aa --- /dev/null +++ b/packages/core-utils/src/common/index.ts @@ -0,0 +1,10 @@ +export * from './array.js' +export * from './random.js' +export * from './date.js' +export * from './number.js' +export * from './object.js' +export * from './regexp.js' +export * from './time.js' +export * from './promises.js' +export * from './url.js' +export * from './version.js' diff --git a/packages/core-utils/src/common/number.ts b/packages/core-utils/src/common/number.ts new file mode 100644 index 000000000..ce5a6041a --- /dev/null +++ b/packages/core-utils/src/common/number.ts @@ -0,0 +1,13 @@ +export function forceNumber (value: any) { + return parseInt(value + '') +} + +export function isOdd (num: number) { + return (num % 2) !== 0 +} + +export function toEven (num: number) { + if (isOdd(num)) return num + 1 + + return num +} diff --git a/packages/core-utils/src/common/object.ts b/packages/core-utils/src/common/object.ts new file mode 100644 index 000000000..1276bfcc7 --- /dev/null +++ b/packages/core-utils/src/common/object.ts @@ -0,0 +1,86 @@ +function pick (object: O, keys: K[]): Pick { + const result: any = {} + + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(object, key)) { + result[key] = object[key] + } + } + + return result +} + +function omit (object: O, keys: K[]): Exclude { + const result: any = {} + const keysSet = new Set(keys) as Set + + for (const [ key, value ] of Object.entries(object)) { + if (keysSet.has(key)) continue + + result[key] = value + } + + return result +} + +function objectKeysTyped (object: O): K[] { + return (Object.keys(object) as K[]) +} + +function getKeys (object: O, keys: K[]): K[] { + return (Object.keys(object) as K[]).filter(k => keys.includes(k)) +} + +function hasKey (obj: T, k: keyof any): k is keyof T { + return k in obj +} + +function sortObjectComparator (key: string, order: 'asc' | 'desc') { + return (a: any, b: any) => { + if (a[key] < b[key]) { + return order === 'asc' ? -1 : 1 + } + + if (a[key] > b[key]) { + return order === 'asc' ? 1 : -1 + } + + return 0 + } +} + +function shallowCopy (o: T): T { + return Object.assign(Object.create(Object.getPrototypeOf(o)), o) +} + +function simpleObjectsDeepEqual (a: any, b: any) { + if (a === b) return true + + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + + const keysA = Object.keys(a) + const keysB = Object.keys(b) + + if (keysA.length !== keysB.length) return false + + for (const key of keysA) { + if (!keysB.includes(key)) return false + + if (!simpleObjectsDeepEqual(a[key], b[key])) return false + } + + return true +} + +export { + pick, + omit, + objectKeysTyped, + getKeys, + hasKey, + shallowCopy, + sortObjectComparator, + simpleObjectsDeepEqual +} diff --git a/packages/core-utils/src/common/promises.ts b/packages/core-utils/src/common/promises.ts new file mode 100644 index 000000000..e3792d12e --- /dev/null +++ b/packages/core-utils/src/common/promises.ts @@ -0,0 +1,58 @@ +export function isPromise (value: T | Promise): value is Promise { + return value && typeof (value as Promise).then === 'function' +} + +export function isCatchable (value: any) { + return value && typeof value.catch === 'function' +} + +export function timeoutPromise (promise: Promise, timeoutMs: number) { + let timer: ReturnType + + return Promise.race([ + promise, + + new Promise((_res, rej) => { + timer = setTimeout(() => rej(new Error('Timeout')), timeoutMs) + }) + ]).finally(() => clearTimeout(timer)) +} + +export function promisify0 (func: (cb: (err: any, result: A) => void) => void): () => Promise { + return function promisified (): Promise { + return new Promise((resolve: (arg: A) => void, reject: (err: any) => void) => { + // eslint-disable-next-line no-useless-call + func.apply(null, [ (err: any, res: A) => err ? reject(err) : resolve(res) ]) + }) + } +} + +// Thanks to https://gist.github.com/kumasento/617daa7e46f13ecdd9b2 +export function promisify1 (func: (arg: T, cb: (err: any, result: A) => void) => void): (arg: T) => Promise { + return function promisified (arg: T): Promise { + return new Promise((resolve: (arg: A) => void, reject: (err: any) => void) => { + // eslint-disable-next-line no-useless-call + func.apply(null, [ arg, (err: any, res: A) => err ? reject(err) : resolve(res) ]) + }) + } +} + +// eslint-disable-next-line max-len +export function promisify2 (func: (arg1: T, arg2: U, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U) => Promise { + return function promisified (arg1: T, arg2: U): Promise { + return new Promise((resolve: (arg: A) => void, reject: (err: any) => void) => { + // eslint-disable-next-line no-useless-call + func.apply(null, [ arg1, arg2, (err: any, res: A) => err ? reject(err) : resolve(res) ]) + }) + } +} + +// eslint-disable-next-line max-len +export function promisify3 (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise { + return function promisified (arg1: T, arg2: U, arg3: V): Promise { + return new Promise((resolve: (arg: A) => void, reject: (err: any) => void) => { + // eslint-disable-next-line no-useless-call + func.apply(null, [ arg1, arg2, arg3, (err: any, res: A) => err ? reject(err) : resolve(res) ]) + }) + } +} diff --git a/packages/core-utils/src/common/random.ts b/packages/core-utils/src/common/random.ts new file mode 100644 index 000000000..705735d09 --- /dev/null +++ b/packages/core-utils/src/common/random.ts @@ -0,0 +1,8 @@ +// high excluded +function randomInt (low: number, high: number) { + return Math.floor(Math.random() * (high - low) + low) +} + +export { + randomInt +} diff --git a/packages/core-utils/src/common/regexp.ts b/packages/core-utils/src/common/regexp.ts new file mode 100644 index 000000000..59eb87eb6 --- /dev/null +++ b/packages/core-utils/src/common/regexp.ts @@ -0,0 +1,5 @@ +export const uuidRegex = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + +export function removeFragmentedMP4Ext (path: string) { + return path.replace(/-fragmented.mp4$/i, '') +} diff --git a/packages/core-utils/src/common/time.ts b/packages/core-utils/src/common/time.ts new file mode 100644 index 000000000..2992609ca --- /dev/null +++ b/packages/core-utils/src/common/time.ts @@ -0,0 +1,7 @@ +function wait (milliseconds: number) { + return new Promise(resolve => setTimeout(resolve, milliseconds)) +} + +export { + wait +} diff --git a/packages/core-utils/src/common/url.ts b/packages/core-utils/src/common/url.ts new file mode 100644 index 000000000..449b6c9dc --- /dev/null +++ b/packages/core-utils/src/common/url.ts @@ -0,0 +1,150 @@ +import { Video, VideoPlaylist } from '@peertube/peertube-models' +import { secondsToTime } from './date.js' + +function addQueryParams (url: string, params: { [ id: string ]: string }) { + const objUrl = new URL(url) + + for (const key of Object.keys(params)) { + objUrl.searchParams.append(key, params[key]) + } + + return objUrl.toString() +} + +function removeQueryParams (url: string) { + const objUrl = new URL(url) + + objUrl.searchParams.forEach((_v, k) => objUrl.searchParams.delete(k)) + + return objUrl.toString() +} + +function buildPlaylistLink (playlist: Pick, base?: string) { + return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist) +} + +function buildPlaylistWatchPath (playlist: Pick) { + return '/w/p/' + playlist.shortUUID +} + +function buildVideoWatchPath (video: Pick) { + return '/w/' + video.shortUUID +} + +function buildVideoLink (video: Pick, base?: string) { + return (base ?? window.location.origin) + buildVideoWatchPath(video) +} + +function buildPlaylistEmbedPath (playlist: Pick) { + return '/video-playlists/embed/' + playlist.uuid +} + +function buildPlaylistEmbedLink (playlist: Pick, base?: string) { + return (base ?? window.location.origin) + buildPlaylistEmbedPath(playlist) +} + +function buildVideoEmbedPath (video: Pick) { + return '/videos/embed/' + video.uuid +} + +function buildVideoEmbedLink (video: Pick, base?: string) { + return (base ?? window.location.origin) + buildVideoEmbedPath(video) +} + +function decorateVideoLink (options: { + url: string + + startTime?: number + stopTime?: number + + subtitle?: string + + loop?: boolean + autoplay?: boolean + muted?: boolean + + // Embed options + title?: boolean + warningTitle?: boolean + + controls?: boolean + controlBar?: boolean + + peertubeLink?: boolean + p2p?: boolean +}) { + const { url } = options + + const params = new URLSearchParams() + + if (options.startTime !== undefined && options.startTime !== null) { + const startTimeInt = Math.floor(options.startTime) + params.set('start', secondsToTime(startTimeInt)) + } + + if (options.stopTime) { + const stopTimeInt = Math.floor(options.stopTime) + params.set('stop', secondsToTime(stopTimeInt)) + } + + if (options.subtitle) params.set('subtitle', options.subtitle) + + if (options.loop === true) params.set('loop', '1') + if (options.autoplay === true) params.set('autoplay', '1') + if (options.muted === true) params.set('muted', '1') + if (options.title === false) params.set('title', '0') + if (options.warningTitle === false) params.set('warningTitle', '0') + + if (options.controls === false) params.set('controls', '0') + if (options.controlBar === false) params.set('controlBar', '0') + + if (options.peertubeLink === false) params.set('peertubeLink', '0') + if (options.p2p !== undefined) params.set('p2p', options.p2p ? '1' : '0') + + return buildUrl(url, params) +} + +function decoratePlaylistLink (options: { + url: string + + playlistPosition?: number +}) { + const { url } = options + + const params = new URLSearchParams() + + if (options.playlistPosition) params.set('playlistPosition', '' + options.playlistPosition) + + return buildUrl(url, params) +} + +// --------------------------------------------------------------------------- + +export { + addQueryParams, + removeQueryParams, + + buildPlaylistLink, + buildVideoLink, + + buildVideoWatchPath, + buildPlaylistWatchPath, + + buildPlaylistEmbedPath, + buildVideoEmbedPath, + + buildPlaylistEmbedLink, + buildVideoEmbedLink, + + decorateVideoLink, + decoratePlaylistLink +} + +function buildUrl (url: string, params: URLSearchParams) { + let hasParams = false + params.forEach(() => { hasParams = true }) + + if (hasParams) return url + '?' + params.toString() + + return url +} diff --git a/packages/core-utils/src/common/version.ts b/packages/core-utils/src/common/version.ts new file mode 100644 index 000000000..305287233 --- /dev/null +++ b/packages/core-utils/src/common/version.ts @@ -0,0 +1,11 @@ +// Thanks https://gist.github.com/iwill/a83038623ba4fef6abb9efca87ae9ccb +function compareSemVer (a: string, b: string) { + if (a.startsWith(b + '-')) return -1 + if (b.startsWith(a + '-')) return 1 + + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' }) +} + +export { + compareSemVer +} diff --git a/packages/core-utils/src/i18n/i18n.ts b/packages/core-utils/src/i18n/i18n.ts new file mode 100644 index 000000000..54b54077a --- /dev/null +++ b/packages/core-utils/src/i18n/i18n.ts @@ -0,0 +1,119 @@ +export const LOCALE_FILES = [ 'player', 'server' ] + +export const I18N_LOCALES = { + // Always first to avoid issues when using express acceptLanguages function when no accept language header is set + 'en-US': 'English', + + // Keep it alphabetically sorted + 'ar': 'العربية', + 'ca-ES': 'Català', + 'cs-CZ': 'Čeština', + 'de-DE': 'Deutsch', + 'el-GR': 'ελληνικά', + 'eo': 'Esperanto', + 'es-ES': 'Español', + 'eu-ES': 'Euskara', + 'fa-IR': 'فارسی', + 'fi-FI': 'Suomi', + 'fr-FR': 'Français', + 'gd': 'Gàidhlig', + 'gl-ES': 'Galego', + 'hr': 'Hrvatski', + 'hu-HU': 'Magyar', + 'is': 'Íslenska', + 'it-IT': 'Italiano', + 'ja-JP': '日本語', + 'kab': 'Taqbaylit', + 'nb-NO': 'Norsk bokmål', + 'nl-NL': 'Nederlands', + 'nn': 'Norsk nynorsk', + 'oc': 'Occitan', + 'pl-PL': 'Polski', + 'pt-BR': 'Português (Brasil)', + 'pt-PT': 'Português (Portugal)', + 'ru-RU': 'Pусский', + 'sq': 'Shqip', + 'sv-SE': 'Svenska', + 'th-TH': 'ไทย', + 'tok': 'Toki Pona', + 'uk-UA': 'украї́нська мо́ва', + 'vi-VN': 'Tiếng Việt', + 'zh-Hans-CN': '简体中文(中国)', + 'zh-Hant-TW': '繁體中文(台灣)' +} + +// Keep it alphabetically sorted +const I18N_LOCALE_ALIAS = { + 'ar-001': 'ar', + 'ca': 'ca-ES', + 'cs': 'cs-CZ', + 'de': 'de-DE', + 'el': 'el-GR', + 'en': 'en-US', + 'es': 'es-ES', + 'eu': 'eu-ES', + 'fa': 'fa-IR', + 'fi': 'fi-FI', + 'fr': 'fr-FR', + 'gl': 'gl-ES', + 'hu': 'hu-HU', + 'it': 'it-IT', + 'ja': 'ja-JP', + 'nb': 'nb-NO', + 'nl': 'nl-NL', + 'pl': 'pl-PL', + 'pt': 'pt-BR', + 'ru': 'ru-RU', + 'sv': 'sv-SE', + 'th': 'th-TH', + 'uk': 'uk-UA', + 'vi': 'vi-VN', + 'zh-CN': 'zh-Hans-CN', + 'zh-Hans': 'zh-Hans-CN', + 'zh-Hant': 'zh-Hant-TW', + 'zh-TW': 'zh-Hant-TW', + 'zh': 'zh-Hans-CN' +} + +export const POSSIBLE_LOCALES = (Object.keys(I18N_LOCALES) as string[]).concat(Object.keys(I18N_LOCALE_ALIAS)) + +export function getDefaultLocale () { + return 'en-US' +} + +export function isDefaultLocale (locale: string) { + return getCompleteLocale(locale) === getCompleteLocale(getDefaultLocale()) +} + +export function peertubeTranslate (str: string, translations?: { [ id: string ]: string }) { + if (!translations?.[str]) return str + + return translations[str] +} + +const possiblePaths = POSSIBLE_LOCALES.map(l => '/' + l) +export function is18nPath (path: string) { + return possiblePaths.includes(path) +} + +export function is18nLocale (locale: string) { + return POSSIBLE_LOCALES.includes(locale) +} + +export function getCompleteLocale (locale: string) { + if (!locale) return locale + + const found = (I18N_LOCALE_ALIAS as any)[locale] as string + + return found || locale +} + +export function getShortLocale (locale: string) { + if (locale.includes('-') === false) return locale + + return locale.split('-')[0] +} + +export function buildFileLocale (locale: string) { + return getCompleteLocale(locale) +} diff --git a/packages/core-utils/src/i18n/index.ts b/packages/core-utils/src/i18n/index.ts new file mode 100644 index 000000000..758e54b73 --- /dev/null +++ b/packages/core-utils/src/i18n/index.ts @@ -0,0 +1 @@ +export * from './i18n.js' diff --git a/packages/core-utils/src/index.ts b/packages/core-utils/src/index.ts new file mode 100644 index 000000000..3ca5d9d47 --- /dev/null +++ b/packages/core-utils/src/index.ts @@ -0,0 +1,7 @@ +export * from './abuse/index.js' +export * from './common/index.js' +export * from './i18n/index.js' +export * from './plugins/index.js' +export * from './renderer/index.js' +export * from './users/index.js' +export * from './videos/index.js' diff --git a/packages/core-utils/src/plugins/hooks.ts b/packages/core-utils/src/plugins/hooks.ts new file mode 100644 index 000000000..fe7c4a74f --- /dev/null +++ b/packages/core-utils/src/plugins/hooks.ts @@ -0,0 +1,60 @@ +import { HookType, HookType_Type, RegisteredExternalAuthConfig } from '@peertube/peertube-models' +import { isCatchable, isPromise } from '../common/promises.js' + +function getHookType (hookName: string) { + if (hookName.startsWith('filter:')) return HookType.FILTER + if (hookName.startsWith('action:')) return HookType.ACTION + + return HookType.STATIC +} + +async function internalRunHook (options: { + handler: Function + hookType: HookType_Type + result: T + params: any + onError: (err: Error) => void +}) { + const { handler, hookType, result, params, onError } = options + + try { + if (hookType === HookType.FILTER) { + const p = handler(result, params) + + const newResult = isPromise(p) + ? await p + : p + + return newResult + } + + // Action/static hooks do not have result value + const p = handler(params) + + if (hookType === HookType.STATIC) { + if (isPromise(p)) await p + + return undefined + } + + if (hookType === HookType.ACTION) { + if (isCatchable(p)) p.catch((err: any) => onError(err)) + + return undefined + } + } catch (err) { + onError(err) + } + + return result +} + +function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) { + return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` +} + +export { + getHookType, + internalRunHook, + getExternalAuthHref +} diff --git a/packages/core-utils/src/plugins/index.ts b/packages/core-utils/src/plugins/index.ts new file mode 100644 index 000000000..3462bf41e --- /dev/null +++ b/packages/core-utils/src/plugins/index.ts @@ -0,0 +1 @@ +export * from './hooks.js' diff --git a/packages/core-utils/src/renderer/html.ts b/packages/core-utils/src/renderer/html.ts new file mode 100644 index 000000000..365bf7612 --- /dev/null +++ b/packages/core-utils/src/renderer/html.ts @@ -0,0 +1,71 @@ +export function getDefaultSanitizeOptions () { + return { + allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], + allowedSchemes: [ 'http', 'https' ], + allowedAttributes: { + 'a': [ 'href', 'class', 'target', 'rel' ], + '*': [ 'data-*' ] + }, + transformTags: { + a: (tagName: string, attribs: any) => { + let rel = 'noopener noreferrer' + if (attribs.rel === 'me') rel += ' me' + + return { + tagName, + attribs: Object.assign(attribs, { + target: '_blank', + rel + }) + } + } + } + } +} + +export function getTextOnlySanitizeOptions () { + return { + allowedTags: [] as string[] + } +} + +export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) { + const base = getDefaultSanitizeOptions() + + return { + allowedTags: [ + ...base.allowedTags, + ...additionalAllowedTags, + 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img' + ], + allowedSchemes: [ + ...base.allowedSchemes, + + 'mailto' + ], + allowedAttributes: { + ...base.allowedAttributes, + + 'img': [ 'src', 'alt' ], + '*': [ 'data-*', 'style' ] + } + } +} + +// Thanks: https://stackoverflow.com/a/12034334 +export function escapeHTML (stringParam: string) { + if (!stringParam) return '' + + const entityMap: { [id: string ]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', + '/': '/', + '`': '`', + '=': '=' + } + + return String(stringParam).replace(/[&<>"'`=/]/g, s => entityMap[s]) +} diff --git a/packages/core-utils/src/renderer/index.ts b/packages/core-utils/src/renderer/index.ts new file mode 100644 index 000000000..0dd0a8808 --- /dev/null +++ b/packages/core-utils/src/renderer/index.ts @@ -0,0 +1,2 @@ +export * from './markdown.js' +export * from './html.js' diff --git a/packages/core-utils/src/renderer/markdown.ts b/packages/core-utils/src/renderer/markdown.ts new file mode 100644 index 000000000..ddf608d7b --- /dev/null +++ b/packages/core-utils/src/renderer/markdown.ts @@ -0,0 +1,24 @@ +export const TEXT_RULES = [ + 'linkify', + 'autolink', + 'emphasis', + 'link', + 'newline', + 'entity', + 'list' +] + +export const TEXT_WITH_HTML_RULES = TEXT_RULES.concat([ + 'html_inline', + 'html_block' +]) + +export const ENHANCED_RULES = TEXT_RULES.concat([ 'image' ]) +export const ENHANCED_WITH_HTML_RULES = TEXT_WITH_HTML_RULES.concat([ 'image' ]) + +export const COMPLETE_RULES = ENHANCED_WITH_HTML_RULES.concat([ + 'block', + 'inline', + 'heading', + 'paragraph' +]) diff --git a/packages/core-utils/src/users/index.ts b/packages/core-utils/src/users/index.ts new file mode 100644 index 000000000..3fd9dc448 --- /dev/null +++ b/packages/core-utils/src/users/index.ts @@ -0,0 +1 @@ +export * from './user-role.js' diff --git a/packages/core-utils/src/users/user-role.ts b/packages/core-utils/src/users/user-role.ts new file mode 100644 index 000000000..0add3a0a8 --- /dev/null +++ b/packages/core-utils/src/users/user-role.ts @@ -0,0 +1,37 @@ +import { UserRight, UserRightType, UserRole, UserRoleType } from '@peertube/peertube-models' + +export const USER_ROLE_LABELS: { [ id in UserRoleType ]: string } = { + [UserRole.USER]: 'User', + [UserRole.MODERATOR]: 'Moderator', + [UserRole.ADMINISTRATOR]: 'Administrator' +} + +const userRoleRights: { [ id in UserRoleType ]: UserRightType[] } = { + [UserRole.ADMINISTRATOR]: [ + UserRight.ALL + ], + + [UserRole.MODERATOR]: [ + UserRight.MANAGE_VIDEO_BLACKLIST, + UserRight.MANAGE_ABUSES, + UserRight.MANAGE_ANY_VIDEO_CHANNEL, + UserRight.REMOVE_ANY_VIDEO, + UserRight.REMOVE_ANY_VIDEO_PLAYLIST, + UserRight.REMOVE_ANY_VIDEO_COMMENT, + UserRight.UPDATE_ANY_VIDEO, + UserRight.SEE_ALL_VIDEOS, + UserRight.MANAGE_ACCOUNTS_BLOCKLIST, + UserRight.MANAGE_SERVERS_BLOCKLIST, + UserRight.MANAGE_USERS, + UserRight.SEE_ALL_COMMENTS, + UserRight.MANAGE_REGISTRATIONS + ], + + [UserRole.USER]: [] +} + +export function hasUserRight (userRole: UserRoleType, userRight: UserRightType) { + const userRights = userRoleRights[userRole] + + return userRights.includes(UserRight.ALL) || userRights.includes(userRight) +} diff --git a/packages/core-utils/src/videos/bitrate.ts b/packages/core-utils/src/videos/bitrate.ts new file mode 100644 index 000000000..b28eaf460 --- /dev/null +++ b/packages/core-utils/src/videos/bitrate.ts @@ -0,0 +1,113 @@ +import { VideoResolution, VideoResolutionType } from '@peertube/peertube-models' + +type BitPerPixel = { [ id in VideoResolutionType ]: number } + +// https://bitmovin.com/video-bitrate-streaming-hls-dash/ + +const minLimitBitPerPixel: BitPerPixel = { + [VideoResolution.H_NOVIDEO]: 0, + [VideoResolution.H_144P]: 0.02, + [VideoResolution.H_240P]: 0.02, + [VideoResolution.H_360P]: 0.02, + [VideoResolution.H_480P]: 0.02, + [VideoResolution.H_720P]: 0.02, + [VideoResolution.H_1080P]: 0.02, + [VideoResolution.H_1440P]: 0.02, + [VideoResolution.H_4K]: 0.02 +} + +const averageBitPerPixel: BitPerPixel = { + [VideoResolution.H_NOVIDEO]: 0, + [VideoResolution.H_144P]: 0.19, + [VideoResolution.H_240P]: 0.17, + [VideoResolution.H_360P]: 0.15, + [VideoResolution.H_480P]: 0.12, + [VideoResolution.H_720P]: 0.11, + [VideoResolution.H_1080P]: 0.10, + [VideoResolution.H_1440P]: 0.09, + [VideoResolution.H_4K]: 0.08 +} + +const maxBitPerPixel: BitPerPixel = { + [VideoResolution.H_NOVIDEO]: 0, + [VideoResolution.H_144P]: 0.32, + [VideoResolution.H_240P]: 0.29, + [VideoResolution.H_360P]: 0.26, + [VideoResolution.H_480P]: 0.22, + [VideoResolution.H_720P]: 0.19, + [VideoResolution.H_1080P]: 0.17, + [VideoResolution.H_1440P]: 0.16, + [VideoResolution.H_4K]: 0.14 +} + +function getAverageTheoreticalBitrate (options: { + resolution: number + ratio: number + fps: number +}) { + const targetBitrate = calculateBitrate({ ...options, bitPerPixel: averageBitPerPixel }) + if (!targetBitrate) return 192 * 1000 + + return targetBitrate +} + +function getMaxTheoreticalBitrate (options: { + resolution: number + ratio: number + fps: number +}) { + const targetBitrate = calculateBitrate({ ...options, bitPerPixel: maxBitPerPixel }) + if (!targetBitrate) return 256 * 1000 + + return targetBitrate +} + +function getMinTheoreticalBitrate (options: { + resolution: number + ratio: number + fps: number +}) { + const minLimitBitrate = calculateBitrate({ ...options, bitPerPixel: minLimitBitPerPixel }) + if (!minLimitBitrate) return 10 * 1000 + + return minLimitBitrate +} + +// --------------------------------------------------------------------------- + +export { + getAverageTheoreticalBitrate, + getMaxTheoreticalBitrate, + getMinTheoreticalBitrate +} + +// --------------------------------------------------------------------------- + +function calculateBitrate (options: { + bitPerPixel: BitPerPixel + resolution: number + ratio: number + fps: number +}) { + const { bitPerPixel, resolution, ratio, fps } = options + + const resolutionsOrder = [ + VideoResolution.H_4K, + VideoResolution.H_1440P, + VideoResolution.H_1080P, + VideoResolution.H_720P, + VideoResolution.H_480P, + VideoResolution.H_360P, + VideoResolution.H_240P, + VideoResolution.H_144P, + VideoResolution.H_NOVIDEO + ] + + for (const toTestResolution of resolutionsOrder) { + if (toTestResolution <= resolution) { + return Math.floor(resolution * resolution * ratio * fps * bitPerPixel[toTestResolution]) + } + } + + throw new Error('Unknown resolution ' + resolution) +} diff --git a/packages/core-utils/src/videos/common.ts b/packages/core-utils/src/videos/common.ts new file mode 100644 index 000000000..47564fb2a --- /dev/null +++ b/packages/core-utils/src/videos/common.ts @@ -0,0 +1,24 @@ +import { VideoDetails, VideoPrivacy, VideoStreamingPlaylistType } from '@peertube/peertube-models' + +function getAllPrivacies () { + return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ] +} + +function getAllFiles (video: Partial>) { + const files = video.files + + const hls = getHLS(video) + if (hls) return files.concat(hls.files) + + return files +} + +function getHLS (video: Partial>) { + return video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) +} + +export { + getAllPrivacies, + getAllFiles, + getHLS +} diff --git a/packages/core-utils/src/videos/index.ts b/packages/core-utils/src/videos/index.ts new file mode 100644 index 000000000..7d3dacdd4 --- /dev/null +++ b/packages/core-utils/src/videos/index.ts @@ -0,0 +1,2 @@ +export * from './bitrate.js' +export * from './common.js' diff --git a/packages/core-utils/tsconfig.json b/packages/core-utils/tsconfig.json new file mode 100644 index 000000000..56ebffbb3 --- /dev/null +++ b/packages/core-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "references": [ + { "path": "../models" } + ] +} diff --git a/packages/ffmpeg/package.json b/packages/ffmpeg/package.json new file mode 100644 index 000000000..fca86df25 --- /dev/null +++ b/packages/ffmpeg/package.json @@ -0,0 +1,19 @@ +{ + "name": "@peertube/peertube-ffmpeg", + "private": true, + "version": "0.0.0", + "main": "dist/index.js", + "files": [ "dist" ], + "exports": { + "types": "./dist/index.d.ts", + "peertube:tsx": "./src/index.ts", + "default": "./dist/index.js" + }, + "type": "module", + "devDependencies": {}, + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "dependencies": {} +} diff --git a/packages/ffmpeg/src/ffmpeg-command-wrapper.ts b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts new file mode 100644 index 000000000..647ee3996 --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts @@ -0,0 +1,246 @@ +import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' +import { pick, promisify0 } from '@peertube/peertube-core-utils' +import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@peertube/peertube-models' + +type FFmpegLogger = { + info: (msg: string, obj?: any) => void + debug: (msg: string, obj?: any) => void + warn: (msg: string, obj?: any) => void + error: (msg: string, obj?: any) => void +} + +export interface FFmpegCommandWrapperOptions { + availableEncoders?: AvailableEncoders + profile?: string + + niceness: number + tmpDirectory: string + threads: number + + logger: FFmpegLogger + lTags?: { tags: string[] } + + updateJobProgress?: (progress?: number) => void + onEnd?: () => void + onError?: (err: Error) => void +} + +export class FFmpegCommandWrapper { + private static supportedEncoders: Map + + private readonly availableEncoders: AvailableEncoders + private readonly profile: string + + private readonly niceness: number + private readonly tmpDirectory: string + private readonly threads: number + + private readonly logger: FFmpegLogger + private readonly lTags: { tags: string[] } + + private readonly updateJobProgress: (progress?: number) => void + private readonly onEnd?: () => void + private readonly onError?: (err: Error) => void + + private command: FfmpegCommand + + constructor (options: FFmpegCommandWrapperOptions) { + this.availableEncoders = options.availableEncoders + this.profile = options.profile + this.niceness = options.niceness + this.tmpDirectory = options.tmpDirectory + this.threads = options.threads + this.logger = options.logger + this.lTags = options.lTags || { tags: [] } + + this.updateJobProgress = options.updateJobProgress + + this.onEnd = options.onEnd + this.onError = options.onError + } + + getAvailableEncoders () { + return this.availableEncoders + } + + getProfile () { + return this.profile + } + + getCommand () { + return this.command + } + + // --------------------------------------------------------------------------- + + debugLog (msg: string, meta: any) { + this.logger.debug(msg, { ...meta, ...this.lTags }) + } + + // --------------------------------------------------------------------------- + + buildCommand (input: string) { + if (this.command) throw new Error('Command is already built') + + // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems + this.command = ffmpeg(input, { + niceness: this.niceness, + cwd: this.tmpDirectory + }) + + if (this.threads > 0) { + // If we don't set any threads ffmpeg will chose automatically + this.command.outputOption('-threads ' + this.threads) + } + + return this.command + } + + async runCommand (options: { + silent?: boolean // false by default + } = {}) { + const { silent = false } = options + + return new Promise((res, rej) => { + let shellCommand: string + + this.command.on('start', cmdline => { shellCommand = cmdline }) + + this.command.on('error', (err, stdout, stderr) => { + if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags }) + + if (this.onError) this.onError(err) + + rej(err) + }) + + this.command.on('end', (stdout, stderr) => { + this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags }) + + if (this.onEnd) this.onEnd() + + res() + }) + + if (this.updateJobProgress) { + this.command.on('progress', progress => { + if (!progress.percent) return + + // Sometimes ffmpeg returns an invalid progress + let percent = Math.round(progress.percent) + if (percent < 0) percent = 0 + if (percent > 100) percent = 100 + + this.updateJobProgress(percent) + }) + } + + this.command.run() + }) + } + + // --------------------------------------------------------------------------- + + static resetSupportedEncoders () { + FFmpegCommandWrapper.supportedEncoders = undefined + } + + // Run encoder builder depending on available encoders + // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one + // If the default one does not exist, check the next encoder + async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & { + streamType: 'video' | 'audio' + input: string + + videoType: 'vod' | 'live' + }) { + if (!this.availableEncoders) { + throw new Error('There is no available encoders') + } + + const { streamType, videoType } = options + + const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType] + const encoders = this.availableEncoders.available[videoType] + + for (const encoder of encodersToTry) { + if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) { + this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags) + continue + } + + if (!encoders[encoder]) { + this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags) + continue + } + + // An object containing available profiles for this encoder + const builderProfiles: EncoderProfile = encoders[encoder] + let builder = builderProfiles[this.profile] + + if (!builder) { + this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags) + builder = builderProfiles.default + + if (!builder) { + this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags) + continue + } + } + + const result = await builder( + pick(options, [ + 'input', + 'canCopyAudio', + 'canCopyVideo', + 'resolution', + 'inputBitrate', + 'fps', + 'inputRatio', + 'streamNum' + ]) + ) + + return { + result, + + // If we don't have output options, then copy the input stream + encoder: result.copy === true + ? 'copy' + : encoder + } + } + + return null + } + + // Detect supported encoders by ffmpeg + private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise> { + if (FFmpegCommandWrapper.supportedEncoders !== undefined) { + return FFmpegCommandWrapper.supportedEncoders + } + + const getAvailableEncodersPromise = promisify0(ffmpeg.getAvailableEncoders) + const availableFFmpegEncoders = await getAvailableEncodersPromise() + + const searchEncoders = new Set() + for (const type of [ 'live', 'vod' ]) { + for (const streamType of [ 'audio', 'video' ]) { + for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) { + searchEncoders.add(encoder) + } + } + } + + const supportedEncoders = new Map() + + for (const searchEncoder of searchEncoders) { + supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined) + } + + this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags }) + + FFmpegCommandWrapper.supportedEncoders = supportedEncoders + return supportedEncoders + } +} diff --git a/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts b/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts new file mode 100644 index 000000000..0d3538512 --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts @@ -0,0 +1,187 @@ +import { FfprobeData } from 'fluent-ffmpeg' +import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, getMinTheoreticalBitrate } from '@peertube/peertube-core-utils' +import { + buildStreamSuffix, + ffprobePromise, + getAudioStream, + getMaxAudioBitrate, + getVideoStream, + getVideoStreamBitrate, + getVideoStreamDimensionsInfo, + getVideoStreamFPS +} from '@peertube/peertube-ffmpeg' +import { EncoderOptionsBuilder, EncoderOptionsBuilderParams } from '@peertube/peertube-models' + +const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => { + const { fps, inputRatio, inputBitrate, resolution } = options + + const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution }) + + return { + outputOptions: [ + ...getCommonOutputOptions(targetBitrate), + + `-r ${fps}` + ] + } +} + +const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => { + const { streamNum, fps, inputBitrate, inputRatio, resolution } = options + + const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution }) + + return { + outputOptions: [ + ...getCommonOutputOptions(targetBitrate, streamNum), + + `${buildStreamSuffix('-r:v', streamNum)} ${fps}`, + `${buildStreamSuffix('-b:v', streamNum)} ${targetBitrate}` + ] + } +} + +const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio }) => { + const probe = await ffprobePromise(input) + + if (canCopyAudio && await canDoQuickAudioTranscode(input, probe)) { + return { copy: true, outputOptions: [ ] } + } + + const parsedAudio = await getAudioStream(input, probe) + + // We try to reduce the ceiling bitrate by making rough matches of bitrates + // Of course this is far from perfect, but it might save some space in the end + + const audioCodecName = parsedAudio.audioStream['codec_name'] + + const bitrate = getMaxAudioBitrate(audioCodecName, parsedAudio.bitrate) + + // Force stereo as it causes some issues with HLS playback in Chrome + const base = [ '-channel_layout', 'stereo' ] + + if (bitrate !== -1) { + return { outputOptions: base.concat([ buildStreamSuffix('-b:a', streamNum), bitrate + 'k' ]) } + } + + return { outputOptions: base } +} + +const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum }) => { + return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] } +} + +export function getDefaultAvailableEncoders () { + return { + vod: { + libx264: { + default: defaultX264VODOptionsBuilder + }, + aac: { + default: defaultAACOptionsBuilder + }, + libfdk_aac: { + default: defaultLibFDKAACVODOptionsBuilder + } + }, + live: { + libx264: { + default: defaultX264LiveOptionsBuilder + }, + aac: { + default: defaultAACOptionsBuilder + } + } + } +} + +export function getDefaultEncodersToTry () { + return { + vod: { + video: [ 'libx264' ], + audio: [ 'libfdk_aac', 'aac' ] + }, + + live: { + video: [ 'libx264' ], + audio: [ 'libfdk_aac', 'aac' ] + } + } +} + +export async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise { + const parsedAudio = await getAudioStream(path, probe) + + if (!parsedAudio.audioStream) return true + + if (parsedAudio.audioStream['codec_name'] !== 'aac') return false + + const audioBitrate = parsedAudio.bitrate + if (!audioBitrate) return false + + const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate) + if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false + + const channelLayout = parsedAudio.audioStream['channel_layout'] + // Causes playback issues with Chrome + if (!channelLayout || channelLayout === 'unknown' || channelLayout === 'quad') return false + + return true +} + +export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise { + const videoStream = await getVideoStream(path, probe) + const fps = await getVideoStreamFPS(path, probe) + const bitRate = await getVideoStreamBitrate(path, probe) + const resolutionData = await getVideoStreamDimensionsInfo(path, probe) + + // If ffprobe did not manage to guess the bitrate + if (!bitRate) return false + + // check video params + if (!videoStream) return false + if (videoStream['codec_name'] !== 'h264') return false + if (videoStream['pix_fmt'] !== 'yuv420p') return false + if (fps < 2 || fps > 65) return false + if (bitRate > getMaxTheoreticalBitrate({ ...resolutionData, fps })) return false + + return true +} + +// --------------------------------------------------------------------------- + +function getTargetBitrate (options: { + inputBitrate: number + resolution: number + ratio: number + fps: number +}) { + const { inputBitrate, resolution, ratio, fps } = options + + const capped = capBitrate(inputBitrate, getAverageTheoreticalBitrate({ resolution, fps, ratio })) + const limit = getMinTheoreticalBitrate({ resolution, fps, ratio }) + + return Math.max(limit, capped) +} + +function capBitrate (inputBitrate: number, targetBitrate: number) { + if (!inputBitrate) return targetBitrate + + // Add 30% margin to input bitrate + const inputBitrateWithMargin = inputBitrate + (inputBitrate * 0.3) + + return Math.min(targetBitrate, inputBitrateWithMargin) +} + +function getCommonOutputOptions (targetBitrate: number, streamNum?: number) { + return [ + `-preset veryfast`, + `${buildStreamSuffix('-maxrate:v', streamNum)} ${targetBitrate}`, + `${buildStreamSuffix('-bufsize:v', streamNum)} ${targetBitrate * 2}`, + + // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it + `-b_strategy 1`, + // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 + `-bf 16` + ] +} diff --git a/packages/ffmpeg/src/ffmpeg-edition.ts b/packages/ffmpeg/src/ffmpeg-edition.ts new file mode 100644 index 000000000..021342930 --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-edition.ts @@ -0,0 +1,239 @@ +import { FilterSpecification } from 'fluent-ffmpeg' +import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' +import { presetVOD } from './shared/presets.js' +import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js' + +export class FFmpegEdition { + private readonly commandWrapper: FFmpegCommandWrapper + + constructor (options: FFmpegCommandWrapperOptions) { + this.commandWrapper = new FFmpegCommandWrapper(options) + } + + async cutVideo (options: { + inputPath: string + outputPath: string + start?: number + end?: number + }) { + const { inputPath, outputPath } = options + + const mainProbe = await ffprobePromise(inputPath) + const fps = await getVideoStreamFPS(inputPath, mainProbe) + const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) + + const command = this.commandWrapper.buildCommand(inputPath) + .output(outputPath) + + await presetVOD({ + commandWrapper: this.commandWrapper, + input: inputPath, + resolution, + fps, + canCopyAudio: false, + canCopyVideo: false + }) + + if (options.start) { + command.outputOption('-ss ' + options.start) + } + + if (options.end) { + command.outputOption('-to ' + options.end) + } + + await this.commandWrapper.runCommand() + } + + async addWatermark (options: { + inputPath: string + watermarkPath: string + outputPath: string + + videoFilters: { + watermarkSizeRatio: number + horitonzalMarginRatio: number + verticalMarginRatio: number + } + }) { + const { watermarkPath, inputPath, outputPath, videoFilters } = options + + const videoProbe = await ffprobePromise(inputPath) + const fps = await getVideoStreamFPS(inputPath, videoProbe) + const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe) + + const command = this.commandWrapper.buildCommand(inputPath) + .output(outputPath) + + command.input(watermarkPath) + + await presetVOD({ + commandWrapper: this.commandWrapper, + input: inputPath, + resolution, + fps, + canCopyAudio: true, + canCopyVideo: false + }) + + const complexFilter: FilterSpecification[] = [ + // Scale watermark + { + inputs: [ '[1]', '[0]' ], + filter: 'scale2ref', + options: { + w: 'oh*mdar', + h: `ih*${videoFilters.watermarkSizeRatio}` + }, + outputs: [ '[watermark]', '[video]' ] + }, + + { + inputs: [ '[video]', '[watermark]' ], + filter: 'overlay', + options: { + x: `main_w - overlay_w - (main_h * ${videoFilters.horitonzalMarginRatio})`, + y: `main_h * ${videoFilters.verticalMarginRatio}` + } + } + ] + + command.complexFilter(complexFilter) + + await this.commandWrapper.runCommand() + } + + async addIntroOutro (options: { + inputPath: string + introOutroPath: string + outputPath: string + type: 'intro' | 'outro' + }) { + const { introOutroPath, inputPath, outputPath, type } = options + + const mainProbe = await ffprobePromise(inputPath) + const fps = await getVideoStreamFPS(inputPath, mainProbe) + const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) + const mainHasAudio = await hasAudioStream(inputPath, mainProbe) + + const introOutroProbe = await ffprobePromise(introOutroPath) + const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe) + + const command = this.commandWrapper.buildCommand(inputPath) + .output(outputPath) + + command.input(introOutroPath) + + if (!introOutroHasAudio && mainHasAudio) { + const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe) + + command.input('anullsrc') + command.withInputFormat('lavfi') + command.withInputOption('-t ' + duration) + } + + await presetVOD({ + commandWrapper: this.commandWrapper, + input: inputPath, + resolution, + fps, + canCopyAudio: false, + canCopyVideo: false + }) + + // Add black background to correctly scale intro/outro with padding + const complexFilter: FilterSpecification[] = [ + { + inputs: [ '1', '0' ], + filter: 'scale2ref', + options: { + w: 'iw', + h: `ih` + }, + outputs: [ 'intro-outro', 'main' ] + }, + { + inputs: [ 'intro-outro', 'main' ], + filter: 'scale2ref', + options: { + w: 'iw', + h: `ih` + }, + outputs: [ 'to-scale', 'main' ] + }, + { + inputs: 'to-scale', + filter: 'drawbox', + options: { + t: 'fill' + }, + outputs: [ 'to-scale-bg' ] + }, + { + inputs: [ '1', 'to-scale-bg' ], + filter: 'scale2ref', + options: { + w: 'iw', + h: 'ih', + force_original_aspect_ratio: 'decrease', + flags: 'spline' + }, + outputs: [ 'to-scale', 'to-scale-bg' ] + }, + { + inputs: [ 'to-scale-bg', 'to-scale' ], + filter: 'overlay', + options: { + x: '(main_w - overlay_w)/2', + y: '(main_h - overlay_h)/2' + }, + outputs: 'intro-outro-resized' + } + ] + + const concatFilter = { + inputs: [], + filter: 'concat', + options: { + n: 2, + v: 1, + unsafe: 1 + }, + outputs: [ 'v' ] + } + + const introOutroFilterInputs = [ 'intro-outro-resized' ] + const mainFilterInputs = [ 'main' ] + + if (mainHasAudio) { + mainFilterInputs.push('0:a') + + if (introOutroHasAudio) { + introOutroFilterInputs.push('1:a') + } else { + // Silent input + introOutroFilterInputs.push('2:a') + } + } + + if (type === 'intro') { + concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ] + } else { + concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ] + } + + if (mainHasAudio) { + concatFilter.options['a'] = 1 + concatFilter.outputs.push('a') + + command.outputOption('-map [a]') + } + + command.outputOption('-map [v]') + + complexFilter.push(concatFilter) + command.complexFilter(complexFilter) + + await this.commandWrapper.runCommand() + } +} diff --git a/packages/ffmpeg/src/ffmpeg-images.ts b/packages/ffmpeg/src/ffmpeg-images.ts new file mode 100644 index 000000000..4cd37aa80 --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-images.ts @@ -0,0 +1,92 @@ +import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' +import { getVideoStreamDuration } from './ffprobe.js' + +export class FFmpegImage { + private readonly commandWrapper: FFmpegCommandWrapper + + constructor (options: FFmpegCommandWrapperOptions) { + this.commandWrapper = new FFmpegCommandWrapper(options) + } + + convertWebPToJPG (options: { + path: string + destination: string + }): Promise { + const { path, destination } = options + + this.commandWrapper.buildCommand(path) + .output(destination) + + return this.commandWrapper.runCommand({ silent: true }) + } + + processGIF (options: { + path: string + destination: string + newSize: { width: number, height: number } + }): Promise { + const { path, destination, newSize } = options + + this.commandWrapper.buildCommand(path) + .fps(20) + .size(`${newSize.width}x${newSize.height}`) + .output(destination) + + return this.commandWrapper.runCommand() + } + + async generateThumbnailFromVideo (options: { + fromPath: string + output: string + }) { + const { fromPath, output } = options + + let duration = await getVideoStreamDuration(fromPath) + if (isNaN(duration)) duration = 0 + + this.commandWrapper.buildCommand(fromPath) + .seekInput(duration / 2) + .videoFilter('thumbnail=500') + .outputOption('-frames:v 1') + .output(output) + + return this.commandWrapper.runCommand() + } + + async generateStoryboardFromVideo (options: { + path: string + destination: string + + sprites: { + size: { + width: number + height: number + } + + count: { + width: number + height: number + } + + duration: number + } + }) { + const { path, destination, sprites } = options + + const command = this.commandWrapper.buildCommand(path) + + const filter = [ + `setpts=N/round(FRAME_RATE)/TB`, + `select='not(mod(t,${options.sprites.duration}))'`, + `scale=${sprites.size.width}:${sprites.size.height}`, + `tile=layout=${sprites.count.width}x${sprites.count.height}` + ].join(',') + + command.outputOption('-filter_complex', filter) + command.outputOption('-frames:v', '1') + command.outputOption('-q:v', '2') + command.output(destination) + + return this.commandWrapper.runCommand() + } +} diff --git a/packages/ffmpeg/src/ffmpeg-live.ts b/packages/ffmpeg/src/ffmpeg-live.ts new file mode 100644 index 000000000..20318f63c --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-live.ts @@ -0,0 +1,184 @@ +import { FilterSpecification } from 'fluent-ffmpeg' +import { join } from 'path' +import { pick } from '@peertube/peertube-core-utils' +import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' +import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-utils.js' +import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js' + +export class FFmpegLive { + private readonly commandWrapper: FFmpegCommandWrapper + + constructor (options: FFmpegCommandWrapperOptions) { + this.commandWrapper = new FFmpegCommandWrapper(options) + } + + async getLiveTranscodingCommand (options: { + inputUrl: string + + outPath: string + masterPlaylistName: string + + toTranscode: { + resolution: number + fps: number + }[] + + // Input information + bitrate: number + ratio: number + hasAudio: boolean + + segmentListSize: number + segmentDuration: number + }) { + const { + inputUrl, + outPath, + toTranscode, + bitrate, + masterPlaylistName, + ratio, + hasAudio + } = options + const command = this.commandWrapper.buildCommand(inputUrl) + + const varStreamMap: string[] = [] + + const complexFilter: FilterSpecification[] = [ + { + inputs: '[v:0]', + filter: 'split', + options: toTranscode.length, + outputs: toTranscode.map(t => `vtemp${t.resolution}`) + } + ] + + command.outputOption('-sc_threshold 0') + + addDefaultEncoderGlobalParams(command) + + for (let i = 0; i < toTranscode.length; i++) { + const streamMap: string[] = [] + const { resolution, fps } = toTranscode[i] + + const baseEncoderBuilderParams = { + input: inputUrl, + + canCopyAudio: true, + canCopyVideo: true, + + inputBitrate: bitrate, + inputRatio: ratio, + + resolution, + fps, + + streamNum: i, + videoType: 'live' as 'live' + } + + { + const streamType: StreamType = 'video' + const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) + if (!builderResult) { + throw new Error('No available live video encoder found') + } + + command.outputOption(`-map [vout${resolution}]`) + + addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i }) + + this.commandWrapper.debugLog( + `Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`, + { builderResult, fps, toTranscode } + ) + + command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`) + applyEncoderOptions(command, builderResult.result) + + complexFilter.push({ + inputs: `vtemp${resolution}`, + filter: getScaleFilter(builderResult.result), + options: `w=-2:h=${resolution}`, + outputs: `vout${resolution}` + }) + + streamMap.push(`v:${i}`) + } + + if (hasAudio) { + const streamType: StreamType = 'audio' + const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) + if (!builderResult) { + throw new Error('No available live audio encoder found') + } + + command.outputOption('-map a:0') + + addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i }) + + this.commandWrapper.debugLog( + `Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`, + { builderResult, fps, resolution } + ) + + command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`) + applyEncoderOptions(command, builderResult.result) + + streamMap.push(`a:${i}`) + } + + varStreamMap.push(streamMap.join(',')) + } + + command.complexFilter(complexFilter) + + this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName }) + + command.outputOption('-var_stream_map', varStreamMap.join(' ')) + + return command + } + + getLiveMuxingCommand (options: { + inputUrl: string + outPath: string + masterPlaylistName: string + + segmentListSize: number + segmentDuration: number + }) { + const { inputUrl, outPath, masterPlaylistName } = options + + const command = this.commandWrapper.buildCommand(inputUrl) + + command.outputOption('-c:v copy') + command.outputOption('-c:a copy') + command.outputOption('-map 0:a?') + command.outputOption('-map 0:v?') + + this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName }) + + return command + } + + private addDefaultLiveHLSParams (options: { + outPath: string + masterPlaylistName: string + segmentListSize: number + segmentDuration: number + }) { + const { outPath, masterPlaylistName, segmentListSize, segmentDuration } = options + + const command = this.commandWrapper.getCommand() + + command.outputOption('-hls_time ' + segmentDuration) + command.outputOption('-hls_list_size ' + segmentListSize) + command.outputOption('-hls_flags delete_segments+independent_segments+program_date_time') + command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) + command.outputOption('-master_pl_name ' + masterPlaylistName) + command.outputOption(`-f hls`) + + command.output(join(outPath, '%v.m3u8')) + } +} diff --git a/packages/ffmpeg/src/ffmpeg-utils.ts b/packages/ffmpeg/src/ffmpeg-utils.ts new file mode 100644 index 000000000..56fd8c0b3 --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-utils.ts @@ -0,0 +1,17 @@ +import { EncoderOptions } from '@peertube/peertube-models' + +export type StreamType = 'audio' | 'video' + +export function buildStreamSuffix (base: string, streamNum?: number) { + if (streamNum !== undefined) { + return `${base}:${streamNum}` + } + + return base +} + +export function getScaleFilter (options: EncoderOptions): string { + if (options.scaleFilter) return options.scaleFilter.name + + return 'scale' +} diff --git a/packages/ffmpeg/src/ffmpeg-version.ts b/packages/ffmpeg/src/ffmpeg-version.ts new file mode 100644 index 000000000..41d9b2d89 --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-version.ts @@ -0,0 +1,24 @@ +import { exec } from 'child_process' +import ffmpeg from 'fluent-ffmpeg' + +export function getFFmpegVersion () { + return new Promise((res, rej) => { + (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => { + if (err) return rej(err) + if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path')) + + return exec(`${ffmpegPath} -version`, (err, stdout) => { + if (err) return rej(err) + + const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/) + if (!parsed?.[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`)) + + // Fix ffmpeg version that does not include patch version (4.4 for example) + let version = parsed[1] + if (version.match(/^\d+\.\d+$/)) { + version += '.0' + } + }) + }) + }) +} diff --git a/packages/ffmpeg/src/ffmpeg-vod.ts b/packages/ffmpeg/src/ffmpeg-vod.ts new file mode 100644 index 000000000..6dd272b8d --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-vod.ts @@ -0,0 +1,256 @@ +import { MutexInterface } from 'async-mutex' +import { FfmpegCommand } from 'fluent-ffmpeg' +import { readFile, writeFile } from 'fs/promises' +import { dirname } from 'path' +import { pick } from '@peertube/peertube-core-utils' +import { VideoResolution } from '@peertube/peertube-models' +import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' +import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js' +import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js' + +export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' + +export interface BaseTranscodeVODOptions { + type: TranscodeVODOptionsType + + inputPath: string + outputPath: string + + // Will be released after the ffmpeg started + // To prevent a bug where the input file does not exist anymore when running ffmpeg + inputFileMutexReleaser: MutexInterface.Releaser + + resolution: number + fps: number +} + +export interface HLSTranscodeOptions extends BaseTranscodeVODOptions { + type: 'hls' + + copyCodecs: boolean + + hlsPlaylist: { + videoFilename: string + } +} + +export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions { + type: 'hls-from-ts' + + isAAC: boolean + + hlsPlaylist: { + videoFilename: string + } +} + +export interface QuickTranscodeOptions extends BaseTranscodeVODOptions { + type: 'quick-transcode' +} + +export interface VideoTranscodeOptions extends BaseTranscodeVODOptions { + type: 'video' +} + +export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions { + type: 'merge-audio' + audioPath: string +} + +export interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions { + type: 'only-audio' +} + +export type TranscodeVODOptions = + HLSTranscodeOptions + | HLSFromTSTranscodeOptions + | VideoTranscodeOptions + | MergeAudioTranscodeOptions + | OnlyAudioTranscodeOptions + | QuickTranscodeOptions + +// --------------------------------------------------------------------------- + +export class FFmpegVOD { + private readonly commandWrapper: FFmpegCommandWrapper + + private ended = false + + constructor (options: FFmpegCommandWrapperOptions) { + this.commandWrapper = new FFmpegCommandWrapper(options) + } + + async transcode (options: TranscodeVODOptions) { + const builders: { + [ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise | void + } = { + 'quick-transcode': this.buildQuickTranscodeCommand.bind(this), + 'hls': this.buildHLSVODCommand.bind(this), + 'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this), + 'merge-audio': this.buildAudioMergeCommand.bind(this), + // TODO: remove, we merge this in buildWebVideoCommand + 'only-audio': this.buildOnlyAudioCommand.bind(this), + 'video': this.buildWebVideoCommand.bind(this) + } + + this.commandWrapper.debugLog('Will run transcode.', { options }) + + const command = this.commandWrapper.buildCommand(options.inputPath) + .output(options.outputPath) + + await builders[options.type](options) + + command.on('start', () => { + setTimeout(() => { + options.inputFileMutexReleaser() + }, 1000) + }) + + await this.commandWrapper.runCommand() + + await this.fixHLSPlaylistIfNeeded(options) + + this.ended = true + } + + isEnded () { + return this.ended + } + + private async buildWebVideoCommand (options: TranscodeVODOptions) { + const { resolution, fps, inputPath } = options + + if (resolution === VideoResolution.H_NOVIDEO) { + presetOnlyAudio(this.commandWrapper) + return + } + + let scaleFilterValue: string + + if (resolution !== undefined) { + const probe = await ffprobePromise(inputPath) + const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe) + + scaleFilterValue = videoStreamInfo?.isPortraitMode === true + ? `w=${resolution}:h=-2` + : `w=-2:h=${resolution}` + } + + await presetVOD({ + commandWrapper: this.commandWrapper, + + resolution, + input: inputPath, + canCopyAudio: true, + canCopyVideo: true, + fps, + scaleFilterValue + }) + } + + private buildQuickTranscodeCommand (_options: TranscodeVODOptions) { + const command = this.commandWrapper.getCommand() + + presetCopy(this.commandWrapper) + + command.outputOption('-map_metadata -1') // strip all metadata + .outputOption('-movflags faststart') + } + + // --------------------------------------------------------------------------- + // Audio transcoding + // --------------------------------------------------------------------------- + + private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) { + const command = this.commandWrapper.getCommand() + + command.loop(undefined) + + await presetVOD({ + ...pick(options, [ 'resolution' ]), + + commandWrapper: this.commandWrapper, + input: options.audioPath, + canCopyAudio: true, + canCopyVideo: true, + fps: options.fps, + scaleFilterValue: this.getMergeAudioScaleFilterValue() + }) + + command.outputOption('-preset:v veryfast') + + command.input(options.audioPath) + .outputOption('-tune stillimage') + .outputOption('-shortest') + } + + private buildOnlyAudioCommand (_options: OnlyAudioTranscodeOptions) { + presetOnlyAudio(this.commandWrapper) + } + + // Avoid "height not divisible by 2" error + private getMergeAudioScaleFilterValue () { + return 'trunc(iw/2)*2:trunc(ih/2)*2' + } + + // --------------------------------------------------------------------------- + // HLS transcoding + // --------------------------------------------------------------------------- + + private async buildHLSVODCommand (options: HLSTranscodeOptions) { + const command = this.commandWrapper.getCommand() + + const videoPath = this.getHLSVideoPath(options) + + if (options.copyCodecs) presetCopy(this.commandWrapper) + else if (options.resolution === VideoResolution.H_NOVIDEO) presetOnlyAudio(this.commandWrapper) + else await this.buildWebVideoCommand(options) + + this.addCommonHLSVODCommandOptions(command, videoPath) + } + + private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) { + const command = this.commandWrapper.getCommand() + + const videoPath = this.getHLSVideoPath(options) + + command.outputOption('-c copy') + + if (options.isAAC) { + // Required for example when copying an AAC stream from an MPEG-TS + // Since it's a bitstream filter, we don't need to reencode the audio + command.outputOption('-bsf:a aac_adtstoasc') + } + + this.addCommonHLSVODCommandOptions(command, videoPath) + } + + private addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) { + return command.outputOption('-hls_time 4') + .outputOption('-hls_list_size 0') + .outputOption('-hls_playlist_type vod') + .outputOption('-hls_segment_filename ' + outputPath) + .outputOption('-hls_segment_type fmp4') + .outputOption('-f hls') + .outputOption('-hls_flags single_file') + } + + private async fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) { + if (options.type !== 'hls' && options.type !== 'hls-from-ts') return + + const fileContent = await readFile(options.outputPath) + + const videoFileName = options.hlsPlaylist.videoFilename + const videoFilePath = this.getHLSVideoPath(options) + + // Fix wrong mapping with some ffmpeg versions + const newContent = fileContent.toString() + .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) + + await writeFile(options.outputPath, newContent) + } + + private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) { + return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` + } +} diff --git a/packages/ffmpeg/src/ffprobe.ts b/packages/ffmpeg/src/ffprobe.ts new file mode 100644 index 000000000..ed1742ab1 --- /dev/null +++ b/packages/ffmpeg/src/ffprobe.ts @@ -0,0 +1,184 @@ +import ffmpeg, { FfprobeData } from 'fluent-ffmpeg' +import { forceNumber } from '@peertube/peertube-core-utils' +import { VideoResolution } from '@peertube/peertube-models' + +/** + * + * Helpers to run ffprobe and extract data from the JSON output + * + */ + +function ffprobePromise (path: string) { + return new Promise((res, rej) => { + ffmpeg.ffprobe(path, (err, data) => { + if (err) return rej(err) + + return res(data) + }) + }) +} + +// --------------------------------------------------------------------------- +// Audio +// --------------------------------------------------------------------------- + +const imageCodecs = new Set([ + 'ansi', 'apng', 'bintext', 'bmp', 'brender_pix', 'dpx', 'exr', 'fits', 'gem', 'gif', 'jpeg2000', 'jpgls', 'mjpeg', 'mjpegb', 'msp2', + 'pam', 'pbm', 'pcx', 'pfm', 'pgm', 'pgmyuv', 'pgx', 'photocd', 'pictor', 'png', 'ppm', 'psd', 'sgi', 'sunrast', 'svg', 'targa', 'tiff', + 'txd', 'webp', 'xbin', 'xbm', 'xface', 'xpm', 'xwd' +]) + +async function isAudioFile (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) return true + + if (imageCodecs.has(videoStream.codec_name)) return true + + return false +} + +async function hasAudioStream (path: string, existingProbe?: FfprobeData) { + const { audioStream } = await getAudioStream(path, existingProbe) + + return !!audioStream +} + +async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) { + // without position, ffprobe considers the last input only + // we make it consider the first input only + // if you pass a file path to pos, then ffprobe acts on that file directly + const data = existingProbe || await ffprobePromise(videoPath) + + if (Array.isArray(data.streams)) { + const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio') + + if (audioStream) { + return { + absolutePath: data.format.filename, + audioStream, + bitrate: forceNumber(audioStream['bit_rate']) + } + } + } + + return { absolutePath: data.format.filename } +} + +function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) { + const maxKBitrate = 384 + const kToBits = (kbits: number) => kbits * 1000 + + // If we did not manage to get the bitrate, use an average value + if (!bitrate) return 256 + + if (type === 'aac') { + switch (true) { + case bitrate > kToBits(maxKBitrate): + return maxKBitrate + + default: + return -1 // we interpret it as a signal to copy the audio stream as is + } + } + + /* + a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac. + That's why, when using aac, we can go to lower kbit/sec. The equivalences + made here are not made to be accurate, especially with good mp3 encoders. + */ + switch (true) { + case bitrate <= kToBits(192): + return 128 + + case bitrate <= kToBits(384): + return 256 + + default: + return maxKBitrate + } +} + +// --------------------------------------------------------------------------- +// Video +// --------------------------------------------------------------------------- + +async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) { + return { + width: 0, + height: 0, + ratio: 0, + resolution: VideoResolution.H_NOVIDEO, + isPortraitMode: false + } + } + + return { + width: videoStream.width, + height: videoStream.height, + ratio: Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width), + resolution: Math.min(videoStream.height, videoStream.width), + isPortraitMode: videoStream.height > videoStream.width + } +} + +async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) return 0 + + for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { + const valuesText: string = videoStream[key] + if (!valuesText) continue + + const [ frames, seconds ] = valuesText.split('/') + if (!frames || !seconds) continue + + const result = parseInt(frames, 10) / parseInt(seconds, 10) + if (result > 0) return Math.round(result) + } + + return 0 +} + +async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise { + const metadata = existingProbe || await ffprobePromise(path) + + let bitrate = metadata.format.bit_rate + if (bitrate && !isNaN(bitrate)) return bitrate + + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) return undefined + + bitrate = forceNumber(videoStream?.bit_rate) + if (bitrate && !isNaN(bitrate)) return bitrate + + return undefined +} + +async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) { + const metadata = existingProbe || await ffprobePromise(path) + + return Math.round(metadata.format.duration) +} + +async function getVideoStream (path: string, existingProbe?: FfprobeData) { + const metadata = existingProbe || await ffprobePromise(path) + + return metadata.streams.find(s => s.codec_type === 'video') +} + +// --------------------------------------------------------------------------- + +export { + getVideoStreamDimensionsInfo, + getMaxAudioBitrate, + getVideoStream, + getVideoStreamDuration, + getAudioStream, + getVideoStreamFPS, + isAudioFile, + ffprobePromise, + getVideoStreamBitrate, + hasAudioStream +} diff --git a/packages/ffmpeg/src/index.ts b/packages/ffmpeg/src/index.ts new file mode 100644 index 000000000..511409a50 --- /dev/null +++ b/packages/ffmpeg/src/index.ts @@ -0,0 +1,9 @@ +export * from './ffmpeg-command-wrapper.js' +export * from './ffmpeg-default-transcoding-profile.js' +export * from './ffmpeg-edition.js' +export * from './ffmpeg-images.js' +export * from './ffmpeg-live.js' +export * from './ffmpeg-utils.js' +export * from './ffmpeg-version.js' +export * from './ffmpeg-vod.js' +export * from './ffprobe.js' diff --git a/packages/ffmpeg/src/shared/encoder-options.ts b/packages/ffmpeg/src/shared/encoder-options.ts new file mode 100644 index 000000000..376a19186 --- /dev/null +++ b/packages/ffmpeg/src/shared/encoder-options.ts @@ -0,0 +1,39 @@ +import { FfmpegCommand } from 'fluent-ffmpeg' +import { EncoderOptions } from '@peertube/peertube-models' +import { buildStreamSuffix } from '../ffmpeg-utils.js' + +export function addDefaultEncoderGlobalParams (command: FfmpegCommand) { + // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375 + command.outputOption('-max_muxing_queue_size 1024') + // strip all metadata + .outputOption('-map_metadata -1') + // allows import of source material with incompatible pixel formats (e.g. MJPEG video) + .outputOption('-pix_fmt yuv420p') +} + +export function addDefaultEncoderParams (options: { + command: FfmpegCommand + encoder: 'libx264' | string + fps: number + + streamNum?: number +}) { + const { command, encoder, fps, streamNum } = options + + if (encoder === 'libx264') { + // 3.1 is the minimal resource allocation for our highest supported resolution + command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1') + + if (fps) { + // Keyframe interval of 2 seconds for faster seeking and resolution switching. + // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html + // https://superuser.com/a/908325 + command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2)) + } + } +} + +export function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions) { + command.inputOptions(options.inputOptions ?? []) + .outputOptions(options.outputOptions ?? []) +} diff --git a/packages/ffmpeg/src/shared/index.ts b/packages/ffmpeg/src/shared/index.ts new file mode 100644 index 000000000..81e8ff0b5 --- /dev/null +++ b/packages/ffmpeg/src/shared/index.ts @@ -0,0 +1,2 @@ +export * from './encoder-options.js' +export * from './presets.js' diff --git a/packages/ffmpeg/src/shared/presets.ts b/packages/ffmpeg/src/shared/presets.ts new file mode 100644 index 000000000..17bd7b031 --- /dev/null +++ b/packages/ffmpeg/src/shared/presets.ts @@ -0,0 +1,93 @@ +import { pick } from '@peertube/peertube-core-utils' +import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper.js' +import { getScaleFilter, StreamType } from '../ffmpeg-utils.js' +import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe.js' +import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options.js' + +export async function presetVOD (options: { + commandWrapper: FFmpegCommandWrapper + + input: string + + canCopyAudio: boolean + canCopyVideo: boolean + + resolution: number + fps: number + + scaleFilterValue?: string +}) { + const { commandWrapper, input, resolution, fps, scaleFilterValue } = options + const command = commandWrapper.getCommand() + + command.format('mp4') + .outputOption('-movflags faststart') + + addDefaultEncoderGlobalParams(command) + + const probe = await ffprobePromise(input) + + // Audio encoder + const bitrate = await getVideoStreamBitrate(input, probe) + const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe) + + let streamsToProcess: StreamType[] = [ 'audio', 'video' ] + + if (!await hasAudioStream(input, probe)) { + command.noAudio() + streamsToProcess = [ 'video' ] + } + + for (const streamType of streamsToProcess) { + const builderResult = await commandWrapper.getEncoderBuilderResult({ + ...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]), + + input, + inputBitrate: bitrate, + inputRatio: videoStreamDimensions?.ratio || 0, + + resolution, + fps, + streamType, + + videoType: 'vod' as 'vod' + }) + + if (!builderResult) { + throw new Error('No available encoder found for stream ' + streamType) + } + + commandWrapper.debugLog( + `Apply ffmpeg params from ${builderResult.encoder} for ${streamType} ` + + `stream of input ${input} using ${commandWrapper.getProfile()} profile.`, + { builderResult, resolution, fps } + ) + + if (streamType === 'video') { + command.videoCodec(builderResult.encoder) + + if (scaleFilterValue) { + command.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`) + } + } else if (streamType === 'audio') { + command.audioCodec(builderResult.encoder) + } + + applyEncoderOptions(command, builderResult.result) + addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps }) + } +} + +export function presetCopy (commandWrapper: FFmpegCommandWrapper) { + commandWrapper.getCommand() + .format('mp4') + .videoCodec('copy') + .audioCodec('copy') +} + +export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) { + commandWrapper.getCommand() + .format('mp4') + .audioCodec('copy') + .noVideo() +} diff --git a/packages/ffmpeg/tsconfig.json b/packages/ffmpeg/tsconfig.json new file mode 100644 index 000000000..c8aeb3c14 --- /dev/null +++ b/packages/ffmpeg/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "references": [ + { "path": "../models" }, + { "path": "../core-utils" } + ] +} diff --git a/packages/models/package.json b/packages/models/package.json new file mode 100644 index 000000000..58a993add --- /dev/null +++ b/packages/models/package.json @@ -0,0 +1,19 @@ +{ + "name": "@peertube/peertube-models", + "private": true, + "version": "0.0.0", + "main": "dist/index.js", + "type": "module", + "files": [ "dist" ], + "exports": { + "types": "./dist/index.d.ts", + "peertube:tsx": "./src/index.ts", + "default": "./dist/index.js" + }, + "devDependencies": {}, + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "dependencies": {} +} diff --git a/packages/models/src/activitypub/activity.ts b/packages/models/src/activitypub/activity.ts new file mode 100644 index 000000000..78a3ab33b --- /dev/null +++ b/packages/models/src/activitypub/activity.ts @@ -0,0 +1,135 @@ +import { ActivityPubActor } from './activitypub-actor.js' +import { ActivityPubSignature } from './activitypub-signature.js' +import { + ActivityFlagReasonObject, + ActivityObject, + APObjectId, + CacheFileObject, + PlaylistObject, + VideoCommentObject, + VideoObject, + WatchActionObject +} from './objects/index.js' + +export type ActivityUpdateObject = + Extract | ActivityPubActor + +// Cannot Extract from Activity because of circular reference +export type ActivityUndoObject = + ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate | ActivityAnnounce + +export type ActivityCreateObject = + Extract + +export type Activity = + ActivityCreate | + ActivityUpdate | + ActivityDelete | + ActivityFollow | + ActivityAccept | + ActivityAnnounce | + ActivityUndo | + ActivityLike | + ActivityReject | + ActivityView | + ActivityDislike | + ActivityFlag + +export type ActivityType = + 'Create' | + 'Update' | + 'Delete' | + 'Follow' | + 'Accept' | + 'Announce' | + 'Undo' | + 'Like' | + 'Reject' | + 'View' | + 'Dislike' | + 'Flag' + +export interface ActivityAudience { + to: string[] + cc: string[] +} + +export interface BaseActivity { + '@context'?: any[] + id: string + to?: string[] + cc?: string[] + actor: string | ActivityPubActor + type: ActivityType + signature?: ActivityPubSignature +} + +export interface ActivityCreate extends BaseActivity { + type: 'Create' + object: T +} + +export interface ActivityUpdate extends BaseActivity { + type: 'Update' + object: T +} + +export interface ActivityDelete extends BaseActivity { + type: 'Delete' + object: APObjectId +} + +export interface ActivityFollow extends BaseActivity { + type: 'Follow' + object: string +} + +export interface ActivityAccept extends BaseActivity { + type: 'Accept' + object: ActivityFollow +} + +export interface ActivityReject extends BaseActivity { + type: 'Reject' + object: ActivityFollow +} + +export interface ActivityAnnounce extends BaseActivity { + type: 'Announce' + object: APObjectId +} + +export interface ActivityUndo extends BaseActivity { + type: 'Undo' + object: T +} + +export interface ActivityLike extends BaseActivity { + type: 'Like' + object: APObjectId +} + +export interface ActivityView extends BaseActivity { + type: 'View' + actor: string + object: APObjectId + + // If sending a "viewer" event + expires?: string +} + +export interface ActivityDislike extends BaseActivity { + id: string + type: 'Dislike' + actor: string + object: APObjectId +} + +export interface ActivityFlag extends BaseActivity { + type: 'Flag' + content: string + object: APObjectId | APObjectId[] + tag?: ActivityFlagReasonObject[] + startAt?: number + endAt?: number +} diff --git a/packages/models/src/activitypub/activitypub-actor.ts b/packages/models/src/activitypub/activitypub-actor.ts new file mode 100644 index 000000000..85b37c5ad --- /dev/null +++ b/packages/models/src/activitypub/activitypub-actor.ts @@ -0,0 +1,34 @@ +import { ActivityIconObject, ActivityPubAttributedTo } from './objects/common-objects.js' + +export type ActivityPubActorType = 'Person' | 'Application' | 'Group' | 'Service' | 'Organization' + +export interface ActivityPubActor { + '@context': any[] + type: ActivityPubActorType + id: string + following: string + followers: string + playlists?: string + inbox: string + outbox: string + preferredUsername: string + url: string + name: string + endpoints: { + sharedInbox: string + } + summary: string + attributedTo: ActivityPubAttributedTo[] + + support?: string + publicKey: { + id: string + owner: string + publicKeyPem: string + } + + image?: ActivityIconObject | ActivityIconObject[] + icon?: ActivityIconObject | ActivityIconObject[] + + published?: string +} diff --git a/packages/models/src/activitypub/activitypub-collection.ts b/packages/models/src/activitypub/activitypub-collection.ts new file mode 100644 index 000000000..b98ad37c2 --- /dev/null +++ b/packages/models/src/activitypub/activitypub-collection.ts @@ -0,0 +1,9 @@ +import { Activity } from './activity.js' + +export interface ActivityPubCollection { + '@context': string[] + type: 'Collection' | 'CollectionPage' + totalItems: number + partOf?: string + items: Activity[] +} diff --git a/packages/models/src/activitypub/activitypub-ordered-collection.ts b/packages/models/src/activitypub/activitypub-ordered-collection.ts new file mode 100644 index 000000000..3de0890bb --- /dev/null +++ b/packages/models/src/activitypub/activitypub-ordered-collection.ts @@ -0,0 +1,10 @@ +export interface ActivityPubOrderedCollection { + '@context': string[] + type: 'OrderedCollection' | 'OrderedCollectionPage' + totalItems: number + orderedItems: T[] + + partOf?: string + next?: string + first?: string +} diff --git a/packages/models/src/activitypub/activitypub-root.ts b/packages/models/src/activitypub/activitypub-root.ts new file mode 100644 index 000000000..2fa1970c7 --- /dev/null +++ b/packages/models/src/activitypub/activitypub-root.ts @@ -0,0 +1,5 @@ +import { Activity } from './activity.js' +import { ActivityPubCollection } from './activitypub-collection.js' +import { ActivityPubOrderedCollection } from './activitypub-ordered-collection.js' + +export type RootActivity = Activity | ActivityPubCollection | ActivityPubOrderedCollection diff --git a/packages/models/src/activitypub/activitypub-signature.ts b/packages/models/src/activitypub/activitypub-signature.ts new file mode 100644 index 000000000..fafdc246d --- /dev/null +++ b/packages/models/src/activitypub/activitypub-signature.ts @@ -0,0 +1,6 @@ +export interface ActivityPubSignature { + type: string + created: Date + creator: string + signatureValue: string +} diff --git a/packages/models/src/activitypub/context.ts b/packages/models/src/activitypub/context.ts new file mode 100644 index 000000000..e9df38207 --- /dev/null +++ b/packages/models/src/activitypub/context.ts @@ -0,0 +1,16 @@ +export type ContextType = + 'Video' | + 'Comment' | + 'Playlist' | + 'Follow' | + 'Reject' | + 'Accept' | + 'View' | + 'Announce' | + 'CacheFile' | + 'Delete' | + 'Rate' | + 'Flag' | + 'Actor' | + 'Collection' | + 'WatchAction' diff --git a/packages/models/src/activitypub/index.ts b/packages/models/src/activitypub/index.ts new file mode 100644 index 000000000..f36aa1bc5 --- /dev/null +++ b/packages/models/src/activitypub/index.ts @@ -0,0 +1,9 @@ +export * from './objects/index.js' +export * from './activity.js' +export * from './activitypub-actor.js' +export * from './activitypub-collection.js' +export * from './activitypub-ordered-collection.js' +export * from './activitypub-root.js' +export * from './activitypub-signature.js' +export * from './context.js' +export * from './webfinger.js' diff --git a/packages/models/src/activitypub/objects/abuse-object.ts b/packages/models/src/activitypub/objects/abuse-object.ts new file mode 100644 index 000000000..2c0f2832b --- /dev/null +++ b/packages/models/src/activitypub/objects/abuse-object.ts @@ -0,0 +1,15 @@ +import { ActivityFlagReasonObject } from './common-objects.js' + +export interface AbuseObject { + type: 'Flag' + + content: string + mediaType: 'text/markdown' + + object: string | string[] + + tag?: ActivityFlagReasonObject[] + + startAt?: number + endAt?: number +} diff --git a/packages/models/src/activitypub/objects/activitypub-object.ts b/packages/models/src/activitypub/objects/activitypub-object.ts new file mode 100644 index 000000000..93c925ae0 --- /dev/null +++ b/packages/models/src/activitypub/objects/activitypub-object.ts @@ -0,0 +1,17 @@ +import { AbuseObject } from './abuse-object.js' +import { CacheFileObject } from './cache-file-object.js' +import { PlaylistObject } from './playlist-object.js' +import { VideoCommentObject } from './video-comment-object.js' +import { VideoObject } from './video-object.js' +import { WatchActionObject } from './watch-action-object.js' + +export type ActivityObject = + VideoObject | + AbuseObject | + VideoCommentObject | + CacheFileObject | + PlaylistObject | + WatchActionObject | + string + +export type APObjectId = string | { id: string } diff --git a/packages/models/src/activitypub/objects/cache-file-object.ts b/packages/models/src/activitypub/objects/cache-file-object.ts new file mode 100644 index 000000000..a40ef339c --- /dev/null +++ b/packages/models/src/activitypub/objects/cache-file-object.ts @@ -0,0 +1,9 @@ +import { ActivityVideoUrlObject, ActivityPlaylistUrlObject } from './common-objects.js' + +export interface CacheFileObject { + id: string + type: 'CacheFile' + object: string + expires: string + url: ActivityVideoUrlObject | ActivityPlaylistUrlObject +} diff --git a/packages/models/src/activitypub/objects/common-objects.ts b/packages/models/src/activitypub/objects/common-objects.ts new file mode 100644 index 000000000..a332c26f3 --- /dev/null +++ b/packages/models/src/activitypub/objects/common-objects.ts @@ -0,0 +1,130 @@ +import { AbusePredefinedReasonsString } from '../../moderation/abuse/abuse-reason.model.js' + +export interface ActivityIdentifierObject { + identifier: string + name: string + url?: string +} + +export interface ActivityIconObject { + type: 'Image' + url: string + mediaType: string + width?: number + height?: number +} + +export type ActivityVideoUrlObject = { + type: 'Link' + mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' + href: string + height: number + size: number + fps: number +} + +export type ActivityPlaylistSegmentHashesObject = { + type: 'Link' + name: 'sha256' + mediaType: 'application/json' + href: string +} + +export type ActivityVideoFileMetadataUrlObject = { + type: 'Link' + rel: [ 'metadata', any ] + mediaType: 'application/json' + height: number + href: string + fps: number +} + +export type ActivityTrackerUrlObject = { + type: 'Link' + rel: [ 'tracker', 'websocket' | 'http' ] + name: string + href: string +} + +export type ActivityStreamingPlaylistInfohashesObject = { + type: 'Infohash' + name: string +} + +export type ActivityPlaylistUrlObject = { + type: 'Link' + mediaType: 'application/x-mpegURL' + href: string + tag?: ActivityTagObject[] +} + +export type ActivityBitTorrentUrlObject = { + type: 'Link' + mediaType: 'application/x-bittorrent' | 'application/x-bittorrent;x-scheme-handler/magnet' + href: string + height: number +} + +export type ActivityMagnetUrlObject = { + type: 'Link' + mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' + href: string + height: number +} + +export type ActivityHtmlUrlObject = { + type: 'Link' + mediaType: 'text/html' + href: string +} + +export interface ActivityHashTagObject { + type: 'Hashtag' + href?: string + name: string +} + +export interface ActivityMentionObject { + type: 'Mention' + href?: string + name: string +} + +export interface ActivityFlagReasonObject { + type: 'Hashtag' + name: AbusePredefinedReasonsString +} + +export type ActivityTagObject = + ActivityPlaylistSegmentHashesObject + | ActivityStreamingPlaylistInfohashesObject + | ActivityVideoUrlObject + | ActivityHashTagObject + | ActivityMentionObject + | ActivityBitTorrentUrlObject + | ActivityMagnetUrlObject + | ActivityVideoFileMetadataUrlObject + +export type ActivityUrlObject = + ActivityVideoUrlObject + | ActivityPlaylistUrlObject + | ActivityBitTorrentUrlObject + | ActivityMagnetUrlObject + | ActivityHtmlUrlObject + | ActivityVideoFileMetadataUrlObject + | ActivityTrackerUrlObject + +export type ActivityPubAttributedTo = { type: 'Group' | 'Person', id: string } | string + +export interface ActivityTombstoneObject { + '@context'?: any + id: string + url?: string + type: 'Tombstone' + name?: string + formerType?: string + inReplyTo?: string + published: string + updated: string + deleted: string +} diff --git a/packages/models/src/activitypub/objects/index.ts b/packages/models/src/activitypub/objects/index.ts new file mode 100644 index 000000000..510f621ea --- /dev/null +++ b/packages/models/src/activitypub/objects/index.ts @@ -0,0 +1,9 @@ +export * from './abuse-object.js' +export * from './activitypub-object.js' +export * from './cache-file-object.js' +export * from './common-objects.js' +export * from './playlist-element-object.js' +export * from './playlist-object.js' +export * from './video-comment-object.js' +export * from './video-object.js' +export * from './watch-action-object.js' diff --git a/packages/models/src/activitypub/objects/playlist-element-object.ts b/packages/models/src/activitypub/objects/playlist-element-object.ts new file mode 100644 index 000000000..b85e4fe19 --- /dev/null +++ b/packages/models/src/activitypub/objects/playlist-element-object.ts @@ -0,0 +1,10 @@ +export interface PlaylistElementObject { + id: string + type: 'PlaylistElement' + + url: string + position: number + + startTimestamp?: number + stopTimestamp?: number +} diff --git a/packages/models/src/activitypub/objects/playlist-object.ts b/packages/models/src/activitypub/objects/playlist-object.ts new file mode 100644 index 000000000..c68a28780 --- /dev/null +++ b/packages/models/src/activitypub/objects/playlist-object.ts @@ -0,0 +1,29 @@ +import { ActivityIconObject, ActivityPubAttributedTo } from './common-objects.js' + +export interface PlaylistObject { + id: string + type: 'Playlist' + + name: string + + content: string + mediaType: 'text/markdown' + + uuid: string + + totalItems: number + attributedTo: ActivityPubAttributedTo[] + + icon?: ActivityIconObject + + published: string + updated: string + + orderedItems?: string[] + + partOf?: string + next?: string + first?: string + + to?: string[] +} diff --git a/packages/models/src/activitypub/objects/video-comment-object.ts b/packages/models/src/activitypub/objects/video-comment-object.ts new file mode 100644 index 000000000..880dd2ee2 --- /dev/null +++ b/packages/models/src/activitypub/objects/video-comment-object.ts @@ -0,0 +1,16 @@ +import { ActivityPubAttributedTo, ActivityTagObject } from './common-objects.js' + +export interface VideoCommentObject { + type: 'Note' + id: string + + content: string + mediaType: 'text/markdown' + + inReplyTo: string + published: string + updated: string + url: string + attributedTo: ActivityPubAttributedTo + tag: ActivityTagObject[] +} diff --git a/packages/models/src/activitypub/objects/video-object.ts b/packages/models/src/activitypub/objects/video-object.ts new file mode 100644 index 000000000..14afd85a2 --- /dev/null +++ b/packages/models/src/activitypub/objects/video-object.ts @@ -0,0 +1,74 @@ +import { LiveVideoLatencyModeType, VideoStateType } from '../../videos/index.js' +import { + ActivityIconObject, + ActivityIdentifierObject, + ActivityPubAttributedTo, + ActivityTagObject, + ActivityUrlObject +} from './common-objects.js' + +export interface VideoObject { + type: 'Video' + id: string + name: string + duration: string + uuid: string + tag: ActivityTagObject[] + category: ActivityIdentifierObject + licence: ActivityIdentifierObject + language: ActivityIdentifierObject + subtitleLanguage: ActivityIdentifierObject[] + views: number + + sensitive: boolean + + isLiveBroadcast: boolean + liveSaveReplay: boolean + permanentLive: boolean + latencyMode: LiveVideoLatencyModeType + + commentsEnabled: boolean + downloadEnabled: boolean + waitTranscoding: boolean + state: VideoStateType + + published: string + originallyPublishedAt: string + updated: string + uploadDate: string + + mediaType: 'text/markdown' + content: string + + support: string + + icon: ActivityIconObject[] + + url: ActivityUrlObject[] + + likes: string + dislikes: string + shares: string + comments: string + + attributedTo: ActivityPubAttributedTo[] + + preview?: ActivityPubStoryboard[] + + to?: string[] + cc?: string[] +} + +export interface ActivityPubStoryboard { + type: 'Image' + rel: [ 'storyboard' ] + url: { + href: string + mediaType: string + width: number + height: number + tileWidth: number + tileHeight: number + tileDuration: string + }[] +} diff --git a/packages/models/src/activitypub/objects/watch-action-object.ts b/packages/models/src/activitypub/objects/watch-action-object.ts new file mode 100644 index 000000000..ed336602f --- /dev/null +++ b/packages/models/src/activitypub/objects/watch-action-object.ts @@ -0,0 +1,22 @@ +export interface WatchActionObject { + id: string + type: 'WatchAction' + + startTime: string + endTime: string + + location?: { + addressCountry: string + } + + uuid: string + object: string + actionStatus: 'CompletedActionStatus' + + duration: string + + watchSections: { + startTimestamp: number + endTimestamp: number + }[] +} diff --git a/packages/models/src/activitypub/webfinger.ts b/packages/models/src/activitypub/webfinger.ts new file mode 100644 index 000000000..b94baf996 --- /dev/null +++ b/packages/models/src/activitypub/webfinger.ts @@ -0,0 +1,9 @@ +export interface WebFingerData { + subject: string + aliases: string[] + links: { + rel: 'self' + type: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' + href: string + }[] +} diff --git a/packages/models/src/actors/account.model.ts b/packages/models/src/actors/account.model.ts new file mode 100644 index 000000000..370a273cf --- /dev/null +++ b/packages/models/src/actors/account.model.ts @@ -0,0 +1,22 @@ +import { ActorImage } from './actor-image.model.js' +import { Actor } from './actor.model.js' + +export interface Account extends Actor { + displayName: string + description: string + avatars: ActorImage[] + + updatedAt: Date | string + + userId?: number +} + +export interface AccountSummary { + id: number + name: string + displayName: string + url: string + host: string + + avatars: ActorImage[] +} diff --git a/packages/models/src/actors/actor-image.model.ts b/packages/models/src/actors/actor-image.model.ts new file mode 100644 index 000000000..cfe44ac15 --- /dev/null +++ b/packages/models/src/actors/actor-image.model.ts @@ -0,0 +1,9 @@ +export interface ActorImage { + width: number + path: string + + url?: string + + createdAt: Date | string + updatedAt: Date | string +} diff --git a/packages/models/src/actors/actor-image.type.ts b/packages/models/src/actors/actor-image.type.ts new file mode 100644 index 000000000..3a808b110 --- /dev/null +++ b/packages/models/src/actors/actor-image.type.ts @@ -0,0 +1,6 @@ +export const ActorImageType = { + AVATAR: 1, + BANNER: 2 +} as const + +export type ActorImageType_Type = typeof ActorImageType[keyof typeof ActorImageType] diff --git a/packages/models/src/actors/actor.model.ts b/packages/models/src/actors/actor.model.ts new file mode 100644 index 000000000..d18053b4b --- /dev/null +++ b/packages/models/src/actors/actor.model.ts @@ -0,0 +1,13 @@ +import { ActorImage } from './actor-image.model.js' + +export interface Actor { + id: number + url: string + name: string + host: string + followingCount: number + followersCount: number + createdAt: Date | string + + avatars: ActorImage[] +} diff --git a/packages/models/src/actors/custom-page.model.ts b/packages/models/src/actors/custom-page.model.ts new file mode 100644 index 000000000..1e33584c1 --- /dev/null +++ b/packages/models/src/actors/custom-page.model.ts @@ -0,0 +1,3 @@ +export interface CustomPage { + content: string +} diff --git a/packages/models/src/actors/follow.model.ts b/packages/models/src/actors/follow.model.ts new file mode 100644 index 000000000..7f3f52ac5 --- /dev/null +++ b/packages/models/src/actors/follow.model.ts @@ -0,0 +1,13 @@ +import { Actor } from './actor.model.js' + +export type FollowState = 'pending' | 'accepted' | 'rejected' + +export interface ActorFollow { + id: number + follower: Actor & { hostRedundancyAllowed: boolean } + following: Actor & { hostRedundancyAllowed: boolean } + score: number + state: FollowState + createdAt: Date + updatedAt: Date +} diff --git a/packages/models/src/actors/index.ts b/packages/models/src/actors/index.ts new file mode 100644 index 000000000..c44063c81 --- /dev/null +++ b/packages/models/src/actors/index.ts @@ -0,0 +1,6 @@ +export * from './account.model.js' +export * from './actor-image.model.js' +export * from './actor-image.type.js' +export * from './actor.model.js' +export * from './custom-page.model.js' +export * from './follow.model.js' diff --git a/packages/models/src/bulk/bulk-remove-comments-of-body.model.ts b/packages/models/src/bulk/bulk-remove-comments-of-body.model.ts new file mode 100644 index 000000000..31e018c2a --- /dev/null +++ b/packages/models/src/bulk/bulk-remove-comments-of-body.model.ts @@ -0,0 +1,4 @@ +export interface BulkRemoveCommentsOfBody { + accountName: string + scope: 'my-videos' | 'instance' +} diff --git a/packages/models/src/bulk/index.ts b/packages/models/src/bulk/index.ts new file mode 100644 index 000000000..3597fda36 --- /dev/null +++ b/packages/models/src/bulk/index.ts @@ -0,0 +1 @@ +export * from './bulk-remove-comments-of-body.model.js' diff --git a/packages/models/src/common/index.ts b/packages/models/src/common/index.ts new file mode 100644 index 000000000..957851ae4 --- /dev/null +++ b/packages/models/src/common/index.ts @@ -0,0 +1 @@ +export * from './result-list.model.js' diff --git a/packages/models/src/common/result-list.model.ts b/packages/models/src/common/result-list.model.ts new file mode 100644 index 000000000..fcafcfb2f --- /dev/null +++ b/packages/models/src/common/result-list.model.ts @@ -0,0 +1,8 @@ +export interface ResultList { + total: number + data: T[] +} + +export interface ThreadsResultList extends ResultList { + totalNotDeletedComments: number +} diff --git a/packages/models/src/custom-markup/custom-markup-data.model.ts b/packages/models/src/custom-markup/custom-markup-data.model.ts new file mode 100644 index 000000000..3e396052a --- /dev/null +++ b/packages/models/src/custom-markup/custom-markup-data.model.ts @@ -0,0 +1,58 @@ +export type EmbedMarkupData = { + // Video or playlist uuid + uuid: string +} + +export type VideoMiniatureMarkupData = { + // Video uuid + uuid: string + + onlyDisplayTitle?: string // boolean +} + +export type PlaylistMiniatureMarkupData = { + // Playlist uuid + uuid: string +} + +export type ChannelMiniatureMarkupData = { + // Channel name (username) + name: string + + displayLatestVideo?: string // boolean + displayDescription?: string // boolean +} + +export type VideosListMarkupData = { + onlyDisplayTitle?: string // boolean + maxRows?: string // number + + sort?: string + count?: string // number + + categoryOneOf?: string // coma separated values, number[] + languageOneOf?: string // coma separated values + + channelHandle?: string + accountHandle?: string + + isLive?: string // number + + onlyLocal?: string // boolean +} + +export type ButtonMarkupData = { + theme: 'primary' | 'secondary' + href: string + label: string + blankTarget?: string // boolean +} + +export type ContainerMarkupData = { + width?: string + title?: string + description?: string + layout?: 'row' | 'column' + + justifyContent?: 'space-between' | 'normal' // default to 'space-between' +} diff --git a/packages/models/src/custom-markup/index.ts b/packages/models/src/custom-markup/index.ts new file mode 100644 index 000000000..1ce10d8e2 --- /dev/null +++ b/packages/models/src/custom-markup/index.ts @@ -0,0 +1 @@ +export * from './custom-markup-data.model.js' diff --git a/packages/models/src/feeds/feed-format.enum.ts b/packages/models/src/feeds/feed-format.enum.ts new file mode 100644 index 000000000..71f2f6c15 --- /dev/null +++ b/packages/models/src/feeds/feed-format.enum.ts @@ -0,0 +1,7 @@ +export const FeedFormat = { + RSS: 'xml', + ATOM: 'atom', + JSON: 'json' +} as const + +export type FeedFormatType = typeof FeedFormat[keyof typeof FeedFormat] diff --git a/packages/models/src/feeds/index.ts b/packages/models/src/feeds/index.ts new file mode 100644 index 000000000..1eda1d6c3 --- /dev/null +++ b/packages/models/src/feeds/index.ts @@ -0,0 +1 @@ +export * from './feed-format.enum.js' diff --git a/packages/models/src/http/http-methods.ts b/packages/models/src/http/http-methods.ts new file mode 100644 index 000000000..ec3c855d8 --- /dev/null +++ b/packages/models/src/http/http-methods.ts @@ -0,0 +1,23 @@ +/** HTTP request method to indicate the desired action to be performed for a given resource. */ +export const HttpMethod = { + /** The CONNECT method establishes a tunnel to the server identified by the target resource. */ + CONNECT: 'CONNECT', + /** The DELETE method deletes the specified resource. */ + DELETE: 'DELETE', + /** The GET method requests a representation of the specified resource. Requests using GET should only retrieve data. */ + GET: 'GET', + /** The HEAD method asks for a response identical to that of a GET request, but without the response body. */ + HEAD: 'HEAD', + /** The OPTIONS method is used to describe the communication options for the target resource. */ + OPTIONS: 'OPTIONS', + /** The PATCH method is used to apply partial modifications to a resource. */ + PATCH: 'PATCH', + /** The POST method is used to submit an entity to the specified resource */ + POST: 'POST', + /** The PUT method replaces all current representations of the target resource with the request payload. */ + PUT: 'PUT', + /** The TRACE method performs a message loop-back test along the path to the target resource. */ + TRACE: 'TRACE' +} as const + +export type HttpMethodType = typeof HttpMethod[keyof typeof HttpMethod] diff --git a/packages/models/src/http/http-status-codes.ts b/packages/models/src/http/http-status-codes.ts new file mode 100644 index 000000000..920b9a2e9 --- /dev/null +++ b/packages/models/src/http/http-status-codes.ts @@ -0,0 +1,366 @@ +/** + * Hypertext Transfer Protocol (HTTP) response status codes. + * @see {@link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes} + * + * WebDAV and other codes useless with regards to PeerTube are not listed. + */ +export const HttpStatusCode = { + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.1 + * + * The server has received the request headers and the client should proceed to send the request body + * (in the case of a request for which a body needs to be sent; for example, a POST request). + * Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient. + * To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request + * and receive a 100 Continue status code in response before sending the body. The response 417 Expectation Failed indicates + * the request should not be continued. + */ + CONTINUE_100: 100, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.2 + * + * This code is sent in response to an Upgrade request header by the client, and indicates the protocol the server is switching too. + */ + SWITCHING_PROTOCOLS_101: 101, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.1 + * + * Standard response for successful HTTP requests. The actual response will depend on the request method used: + * GET: The resource has been fetched and is transmitted in the message body. + * HEAD: The entity headers are in the message body. + * POST: The resource describing the result of the action is transmitted in the message body. + * TRACE: The message body contains the request message as received by the server + */ + OK_200: 200, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.2 + * + * The request has been fulfilled, resulting in the creation of a new resource, typically after a PUT. + */ + CREATED_201: 201, + + /** + * The request has been accepted for processing, but the processing has not been completed. + * The request might or might not be eventually acted upon, and may be disallowed when processing occurs. + */ + ACCEPTED_202: 202, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.5 + * + * There is no content to send for this request, but the headers may be useful. + * The user-agent may update its cached headers for this resource with the new ones. + */ + NO_CONTENT_204: 204, + + /** + * The server successfully processed the request, but is not returning any content. + * Unlike a 204 response, this response requires that the requester reset the document view. + */ + RESET_CONTENT_205: 205, + + /** + * The server is delivering only part of the resource (byte serving) due to a range header sent by the client. + * The range header is used by HTTP clients to enable resuming of interrupted downloads, + * or split a download into multiple simultaneous streams. + */ + PARTIAL_CONTENT_206: 206, + + /** + * Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation). + * For example, this code could be used to present multiple video format options, + * to list files with different filename extensions, or to suggest word-sense disambiguation. + */ + MULTIPLE_CHOICES_300: 300, + + /** + * This and all future requests should be directed to the given URI. + */ + MOVED_PERMANENTLY_301: 301, + + /** + * This is an example of industry practice contradicting the standard. + * The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect + * (the original describing phrase was "Moved Temporarily"), but popular browsers implemented 302 + * with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307 + * to distinguish between the two behaviours. However, some Web applications and frameworks + * use the 302 status code as if it were the 303. + */ + FOUND_302: 302, + + /** + * SINCE HTTP/1.1 + * The response to the request can be found under another URI using a GET method. + * When received in response to a POST (or PUT/DELETE), the client should presume that + * the server has received the data and should issue a redirect with a separate GET message. + */ + SEE_OTHER_303: 303, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.1 + * + * Indicates that the resource has not been modified since the version specified by the request headers + * `If-Modified-Since` or `If-None-Match`. + * In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy. + */ + NOT_MODIFIED_304: 304, + + /** + * SINCE HTTP/1.1 + * In this case, the request should be repeated with another URI; however, future requests should still use the original URI. + * In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the + * original request. + * For example, a POST request should be repeated using another POST request. + */ + TEMPORARY_REDIRECT_307: 307, + + /** + * The request and all future requests should be repeated using another URI. + * 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change. + * So, for example, submitting a form to a permanently redirected resource may continue smoothly. + */ + PERMANENT_REDIRECT_308: 308, + + /** + * The server cannot or will not process the request due to an apparent client error + * (e.g., malformed request syntax, too large size, invalid request message framing, or deceptive request routing). + */ + BAD_REQUEST_400: 400, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.1 + * + * Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet + * been provided. The response must include a `WWW-Authenticate` header field containing a challenge applicable to the + * requested resource. See Basic access authentication and Digest access authentication. 401 semantically means + * "unauthenticated",i.e. the user does not have the necessary credentials. + */ + UNAUTHORIZED_401: 401, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.2 + * + * Reserved for future use. The original intention was that this code might be used as part of some form of digital + * cash or micro payment scheme, but that has not happened, and this code is not usually used. + * Google Developers API uses this status if a particular developer has exceeded the daily limit on requests. + */ + PAYMENT_REQUIRED_402: 402, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.3 + * + * The client does not have access rights to the content, i.e. they are unauthorized, so server is rejecting to + * give proper response. Unlike 401, the client's identity is known to the server. + */ + FORBIDDEN_403: 403, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2 + * + * The requested resource could not be found but may be available in the future. + * Subsequent requests by the client are permissible. + */ + NOT_FOUND_404: 404, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.5 + * + * A request method is not supported for the requested resource; + * for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource. + */ + METHOD_NOT_ALLOWED_405: 405, + + /** + * The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request. + */ + NOT_ACCEPTABLE_406: 406, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.7 + * + * This response is sent on an idle connection by some servers, even without any previous request by the client. + * It means that the server would like to shut down this unused connection. This response is used much more since + * some browsers, like Chrome, Firefox 27+, or IE9, use HTTP pre-connection mechanisms to speed up surfing. Also + * note that some servers merely shut down the connection without sending this message. + * + * @ + */ + REQUEST_TIMEOUT_408: 408, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.8 + * + * Indicates that the request could not be processed because of conflict in the request, + * such as an edit conflict between multiple simultaneous updates. + * + * @see HttpStatusCode.UNPROCESSABLE_ENTITY_422 to denote a disabled feature + */ + CONFLICT_409: 409, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.9 + * + * Indicates that the resource requested is no longer available and will not be available again. + * This should be used when a resource has been intentionally removed and the resource should be purged. + * Upon receiving a 410 status code, the client should not request the resource in the future. + * Clients such as search engines should remove the resource from their indices. + * Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead. + */ + GONE_410: 410, + + /** + * The request did not specify the length of its content, which is required by the requested resource. + */ + LENGTH_REQUIRED_411: 411, + + /** + * The server does not meet one of the preconditions that the requester put on the request. + */ + PRECONDITION_FAILED_412: 412, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.11 + * + * The request is larger than the server is willing or able to process ; the server might close the connection + * or return an Retry-After header field. + * Previously called "Request Entity Too Large". + */ + PAYLOAD_TOO_LARGE_413: 413, + + /** + * The URI provided was too long for the server to process. Often the result of too much data being encoded as a + * query-string of a GET request, in which case it should be converted to a POST request. + * Called "Request-URI Too Long" previously. + */ + URI_TOO_LONG_414: 414, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.13 + * + * The request entity has a media type which the server or resource does not support. + * For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format. + */ + UNSUPPORTED_MEDIA_TYPE_415: 415, + + /** + * The client has asked for a portion of the file (byte serving), but the server cannot supply that portion. + * For example, if the client asked for a part of the file that lies beyond the end of the file. + * Called "Requested Range Not Satisfiable" previously. + */ + RANGE_NOT_SATISFIABLE_416: 416, + + /** + * The server cannot meet the requirements of the `Expect` request-header field. + */ + EXPECTATION_FAILED_417: 417, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2324 + * + * This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol, + * and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by + * teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including PeerTube instances ;-). + */ + I_AM_A_TEAPOT_418: 418, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.3 + * + * The request was well-formed but was unable to be followed due to semantic errors. + * The server understands the content type of the request entity (hence a 415 (Unsupported Media Type) status code is inappropriate), + * and the syntax of the request entity is correct (thus a 400 (Bad Request) status code is inappropriate) but was unable to process + * the contained instructions. For example, this error condition may occur if an JSON request body contains well-formed (i.e., + * syntactically correct), but semantically erroneous, JSON instructions. + * + * Can also be used to denote disabled features (akin to disabled syntax). + * + * @see HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 if the `Content-Type` was not supported. + * @see HttpStatusCode.BAD_REQUEST_400 if the request was not parsable (broken JSON, XML) + */ + UNPROCESSABLE_ENTITY_422: 422, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc4918#section-11.3 + * + * The resource that is being accessed is locked. WebDAV-specific but used by some HTTP services. + * + * @deprecated use `If-Match` / `If-None-Match` instead + * @see {@link https://evertpot.com/http/423-locked} + */ + LOCKED_423: 423, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-4 + * + * The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes. + */ + TOO_MANY_REQUESTS_429: 429, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc6585#section-5 + * + * The server is unwilling to process the request because either an individual header field, + * or all the header fields collectively, are too large. + */ + REQUEST_HEADER_FIELDS_TOO_LARGE_431: 431, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7725 + * + * A server operator has received a legal demand to deny access to a resource or to a set of resources + * that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451. + */ + UNAVAILABLE_FOR_LEGAL_REASONS_451: 451, + + /** + * A generic error message, given when an unexpected condition was encountered and no more specific message is suitable. + */ + INTERNAL_SERVER_ERROR_500: 500, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2 + * + * The server either does not recognize the request method, or it lacks the ability to fulfill the request. + * Usually this implies future availability (e.g., a new feature of a web-service API). + */ + NOT_IMPLEMENTED_501: 501, + + /** + * The server was acting as a gateway or proxy and received an invalid response from the upstream server. + */ + BAD_GATEWAY_502: 502, + + /** + * The server is currently unavailable (because it is overloaded or down for maintenance). + * Generally, this is a temporary state. + */ + SERVICE_UNAVAILABLE_503: 503, + + /** + * The server was acting as a gateway or proxy and did not receive a timely response from the upstream server. + */ + GATEWAY_TIMEOUT_504: 504, + + /** + * The server does not support the HTTP protocol version used in the request + */ + HTTP_VERSION_NOT_SUPPORTED_505: 505, + + /** + * Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6 + * + * The 507 (Insufficient Storage) status code means the method could not be performed on the resource because the + * server is unable to store the representation needed to successfully complete the request. This condition is + * considered to be temporary. If the request which received this status code was the result of a user action, + * the request MUST NOT be repeated until it is requested by a separate user action. + * + * @see HttpStatusCode.PAYLOAD_TOO_LARGE_413 for quota errors + */ + INSUFFICIENT_STORAGE_507: 507 +} as const + +export type HttpStatusCodeType = typeof HttpStatusCode[keyof typeof HttpStatusCode] diff --git a/packages/models/src/http/index.ts b/packages/models/src/http/index.ts new file mode 100644 index 000000000..f0ad040ed --- /dev/null +++ b/packages/models/src/http/index.ts @@ -0,0 +1,2 @@ +export * from './http-status-codes.js' +export * from './http-methods.js' diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts new file mode 100644 index 000000000..b76703dff --- /dev/null +++ b/packages/models/src/index.ts @@ -0,0 +1,20 @@ +export * from './activitypub/index.js' +export * from './actors/index.js' +export * from './bulk/index.js' +export * from './common/index.js' +export * from './custom-markup/index.js' +export * from './feeds/index.js' +export * from './http/index.js' +export * from './joinpeertube/index.js' +export * from './metrics/index.js' +export * from './moderation/index.js' +export * from './nodeinfo/index.js' +export * from './overviews/index.js' +export * from './plugins/index.js' +export * from './redundancy/index.js' +export * from './runners/index.js' +export * from './search/index.js' +export * from './server/index.js' +export * from './tokens/index.js' +export * from './users/index.js' +export * from './videos/index.js' diff --git a/packages/models/src/joinpeertube/index.ts b/packages/models/src/joinpeertube/index.ts new file mode 100644 index 000000000..a51d34190 --- /dev/null +++ b/packages/models/src/joinpeertube/index.ts @@ -0,0 +1 @@ +export * from './versions.model.js' diff --git a/packages/models/src/joinpeertube/versions.model.ts b/packages/models/src/joinpeertube/versions.model.ts new file mode 100644 index 000000000..60a769150 --- /dev/null +++ b/packages/models/src/joinpeertube/versions.model.ts @@ -0,0 +1,5 @@ +export interface JoinPeerTubeVersions { + peertube: { + latestVersion: string + } +} diff --git a/packages/models/src/metrics/index.ts b/packages/models/src/metrics/index.ts new file mode 100644 index 000000000..def6f8095 --- /dev/null +++ b/packages/models/src/metrics/index.ts @@ -0,0 +1 @@ +export * from './playback-metric-create.model.js' diff --git a/packages/models/src/metrics/playback-metric-create.model.ts b/packages/models/src/metrics/playback-metric-create.model.ts new file mode 100644 index 000000000..3ae91b295 --- /dev/null +++ b/packages/models/src/metrics/playback-metric-create.model.ts @@ -0,0 +1,22 @@ +import { VideoResolutionType } from '../videos/index.js' + +export interface PlaybackMetricCreate { + playerMode: 'p2p-media-loader' | 'webtorrent' | 'web-video' // FIXME: remove webtorrent player mode not used anymore in PeerTube v6 + + resolution?: VideoResolutionType + fps?: number + + p2pEnabled: boolean + p2pPeers?: number + + resolutionChanges: number + + errors: number + + downloadedBytesP2P: number + downloadedBytesHTTP: number + + uploadedBytesP2P: number + + videoId: number | string +} diff --git a/packages/models/src/moderation/abuse/abuse-create.model.ts b/packages/models/src/moderation/abuse/abuse-create.model.ts new file mode 100644 index 000000000..1c2723b1c --- /dev/null +++ b/packages/models/src/moderation/abuse/abuse-create.model.ts @@ -0,0 +1,21 @@ +import { AbusePredefinedReasonsString } from './abuse-reason.model.js' + +export interface AbuseCreate { + reason: string + + predefinedReasons?: AbusePredefinedReasonsString[] + + account?: { + id: number + } + + video?: { + id: number | string + startAt?: number + endAt?: number + } + + comment?: { + id: number + } +} diff --git a/packages/models/src/moderation/abuse/abuse-filter.type.ts b/packages/models/src/moderation/abuse/abuse-filter.type.ts new file mode 100644 index 000000000..7dafc6d77 --- /dev/null +++ b/packages/models/src/moderation/abuse/abuse-filter.type.ts @@ -0,0 +1 @@ +export type AbuseFilter = 'video' | 'comment' | 'account' diff --git a/packages/models/src/moderation/abuse/abuse-message.model.ts b/packages/models/src/moderation/abuse/abuse-message.model.ts new file mode 100644 index 000000000..9ba95e724 --- /dev/null +++ b/packages/models/src/moderation/abuse/abuse-message.model.ts @@ -0,0 +1,10 @@ +import { AccountSummary } from '../../actors/account.model.js' + +export interface AbuseMessage { + id: number + message: string + byModerator: boolean + createdAt: Date | string + + account: AccountSummary +} diff --git a/packages/models/src/moderation/abuse/abuse-reason.model.ts b/packages/models/src/moderation/abuse/abuse-reason.model.ts new file mode 100644 index 000000000..770b9d47a --- /dev/null +++ b/packages/models/src/moderation/abuse/abuse-reason.model.ts @@ -0,0 +1,22 @@ +export const AbusePredefinedReasons = { + VIOLENT_OR_REPULSIVE: 1, + HATEFUL_OR_ABUSIVE: 2, + SPAM_OR_MISLEADING: 3, + PRIVACY: 4, + RIGHTS: 5, + SERVER_RULES: 6, + THUMBNAILS: 7, + CAPTIONS: 8 +} as const + +export type AbusePredefinedReasonsType = typeof AbusePredefinedReasons[keyof typeof AbusePredefinedReasons] + +export type AbusePredefinedReasonsString = + 'violentOrRepulsive' | + 'hatefulOrAbusive' | + 'spamOrMisleading' | + 'privacy' | + 'rights' | + 'serverRules' | + 'thumbnails' | + 'captions' diff --git a/packages/models/src/moderation/abuse/abuse-state.model.ts b/packages/models/src/moderation/abuse/abuse-state.model.ts new file mode 100644 index 000000000..5582d73c4 --- /dev/null +++ b/packages/models/src/moderation/abuse/abuse-state.model.ts @@ -0,0 +1,7 @@ +export const AbuseState = { + PENDING: 1, + REJECTED: 2, + ACCEPTED: 3 +} as const + +export type AbuseStateType = typeof AbuseState[keyof typeof AbuseState] diff --git a/packages/models/src/moderation/abuse/abuse-update.model.ts b/packages/models/src/moderation/abuse/abuse-update.model.ts new file mode 100644 index 000000000..22a01be89 --- /dev/null +++ b/packages/models/src/moderation/abuse/abuse-update.model.ts @@ -0,0 +1,7 @@ +import { AbuseStateType } from './abuse-state.model.js' + +export interface AbuseUpdate { + moderationComment?: string + + state?: AbuseStateType +} diff --git a/packages/models/src/moderation/abuse/abuse-video-is.type.ts b/packages/models/src/moderation/abuse/abuse-video-is.type.ts new file mode 100644 index 000000000..74937f3b9 --- /dev/null +++ b/packages/models/src/moderation/abuse/abuse-video-is.type.ts @@ -0,0 +1 @@ +export type AbuseVideoIs = 'deleted' | 'blacklisted' diff --git a/packages/models/src/moderation/abuse/abuse.model.ts b/packages/models/src/moderation/abuse/abuse.model.ts new file mode 100644 index 000000000..253a3f44c --- /dev/null +++ b/packages/models/src/moderation/abuse/abuse.model.ts @@ -0,0 +1,70 @@ +import { Account } from '../../actors/account.model.js' +import { AbuseStateType } from './abuse-state.model.js' +import { AbusePredefinedReasonsString } from './abuse-reason.model.js' +import { VideoConstant } from '../../videos/video-constant.model.js' +import { VideoChannel } from '../../videos/channel/video-channel.model.js' + +export interface AdminVideoAbuse { + id: number + name: string + uuid: string + nsfw: boolean + + deleted: boolean + blacklisted: boolean + + startAt: number | null + endAt: number | null + + thumbnailPath?: string + channel?: VideoChannel + + countReports: number + nthReport: number +} + +export interface AdminVideoCommentAbuse { + id: number + threadId: number + + video: { + id: number + name: string + uuid: string + } + + text: string + + deleted: boolean +} + +export interface AdminAbuse { + id: number + + reason: string + predefinedReasons?: AbusePredefinedReasonsString[] + + reporterAccount: Account + flaggedAccount: Account + + state: VideoConstant + moderationComment?: string + + video?: AdminVideoAbuse + comment?: AdminVideoCommentAbuse + + createdAt: Date + updatedAt: Date + + countReportsForReporter?: number + countReportsForReportee?: number + + countMessages: number +} + +export type UserVideoAbuse = Omit + +export type UserVideoCommentAbuse = AdminVideoCommentAbuse + +export type UserAbuse = Omit diff --git a/packages/models/src/moderation/abuse/index.ts b/packages/models/src/moderation/abuse/index.ts new file mode 100644 index 000000000..27fca7076 --- /dev/null +++ b/packages/models/src/moderation/abuse/index.ts @@ -0,0 +1,8 @@ +export * from './abuse-create.model.js' +export * from './abuse-filter.type.js' +export * from './abuse-message.model.js' +export * from './abuse-reason.model.js' +export * from './abuse-state.model.js' +export * from './abuse-update.model.js' +export * from './abuse-video-is.type.js' +export * from './abuse.model.js' diff --git a/packages/models/src/moderation/account-block.model.ts b/packages/models/src/moderation/account-block.model.ts new file mode 100644 index 000000000..2d070da62 --- /dev/null +++ b/packages/models/src/moderation/account-block.model.ts @@ -0,0 +1,7 @@ +import { Account } from '../actors/index.js' + +export interface AccountBlock { + byAccount: Account + blockedAccount: Account + createdAt: Date | string +} diff --git a/packages/models/src/moderation/block-status.model.ts b/packages/models/src/moderation/block-status.model.ts new file mode 100644 index 000000000..597312757 --- /dev/null +++ b/packages/models/src/moderation/block-status.model.ts @@ -0,0 +1,15 @@ +export interface BlockStatus { + accounts: { + [ handle: string ]: { + blockedByServer: boolean + blockedByUser?: boolean + } + } + + hosts: { + [ host: string ]: { + blockedByServer: boolean + blockedByUser?: boolean + } + } +} diff --git a/packages/models/src/moderation/index.ts b/packages/models/src/moderation/index.ts new file mode 100644 index 000000000..52e21e7b3 --- /dev/null +++ b/packages/models/src/moderation/index.ts @@ -0,0 +1,4 @@ +export * from './abuse/index.js' +export * from './block-status.model.js' +export * from './account-block.model.js' +export * from './server-block.model.js' diff --git a/packages/models/src/moderation/server-block.model.ts b/packages/models/src/moderation/server-block.model.ts new file mode 100644 index 000000000..b85646fc6 --- /dev/null +++ b/packages/models/src/moderation/server-block.model.ts @@ -0,0 +1,9 @@ +import { Account } from '../actors/index.js' + +export interface ServerBlock { + byAccount: Account + blockedServer: { + host: string + } + createdAt: Date | string +} diff --git a/packages/models/src/nodeinfo/index.ts b/packages/models/src/nodeinfo/index.ts new file mode 100644 index 000000000..932288795 --- /dev/null +++ b/packages/models/src/nodeinfo/index.ts @@ -0,0 +1 @@ +export * from './nodeinfo.model.js' diff --git a/packages/models/src/nodeinfo/nodeinfo.model.ts b/packages/models/src/nodeinfo/nodeinfo.model.ts new file mode 100644 index 000000000..336cb66d2 --- /dev/null +++ b/packages/models/src/nodeinfo/nodeinfo.model.ts @@ -0,0 +1,117 @@ +/** + * NodeInfo schema version 2.0. + */ +export interface HttpNodeinfoDiasporaSoftwareNsSchema20 { + /** + * The schema version, must be 2.0. + */ + version: '2.0' + /** + * Metadata about server software in use. + */ + software: { + /** + * The canonical name of this server software. + */ + name: string + /** + * The version of this server software. + */ + version: string + } + /** + * The protocols supported on this server. + */ + protocols: ( + | 'activitypub' + | 'buddycloud' + | 'dfrn' + | 'diaspora' + | 'libertree' + | 'ostatus' + | 'pumpio' + | 'tent' + | 'xmpp' + | 'zot')[] + /** + * The third party sites this server can connect to via their application API. + */ + services: { + /** + * The third party sites this server can retrieve messages from for combined display with regular traffic. + */ + inbound: ('atom1.0' | 'gnusocial' | 'imap' | 'pnut' | 'pop3' | 'pumpio' | 'rss2.0' | 'twitter')[] + /** + * The third party sites this server can publish messages to on the behalf of a user. + */ + outbound: ( + | 'atom1.0' + | 'blogger' + | 'buddycloud' + | 'diaspora' + | 'dreamwidth' + | 'drupal' + | 'facebook' + | 'friendica' + | 'gnusocial' + | 'google' + | 'insanejournal' + | 'libertree' + | 'linkedin' + | 'livejournal' + | 'mediagoblin' + | 'myspace' + | 'pinterest' + | 'pnut' + | 'posterous' + | 'pumpio' + | 'redmatrix' + | 'rss2.0' + | 'smtp' + | 'tent' + | 'tumblr' + | 'twitter' + | 'wordpress' + | 'xmpp')[] + } + /** + * Whether this server allows open self-registration. + */ + openRegistrations: boolean + /** + * Usage statistics for this server. + */ + usage: { + /** + * statistics about the users of this server. + */ + users: { + /** + * The total amount of on this server registered users. + */ + total?: number + /** + * The amount of users that signed in at least once in the last 180 days. + */ + activeHalfyear?: number + /** + * The amount of users that signed in at least once in the last 30 days. + */ + activeMonth?: number + } + /** + * The amount of posts that were made by users that are registered on this server. + */ + localPosts?: number + /** + * The amount of comments that were made by users that are registered on this server. + */ + localComments?: number + } + /** + * Free form key value pairs for software specific values. Clients should not rely on any specific key present. + */ + metadata: { + [k: string]: any + } +} diff --git a/packages/models/src/overviews/index.ts b/packages/models/src/overviews/index.ts new file mode 100644 index 000000000..20dc105e2 --- /dev/null +++ b/packages/models/src/overviews/index.ts @@ -0,0 +1 @@ +export * from './videos-overview.model.js' diff --git a/packages/models/src/overviews/videos-overview.model.ts b/packages/models/src/overviews/videos-overview.model.ts new file mode 100644 index 000000000..3a1ba1760 --- /dev/null +++ b/packages/models/src/overviews/videos-overview.model.ts @@ -0,0 +1,24 @@ +import { Video, VideoChannelSummary, VideoConstant } from '../videos/index.js' + +export interface ChannelOverview { + channel: VideoChannelSummary + videos: Video[] +} + +export interface CategoryOverview { + category: VideoConstant + videos: Video[] +} + +export interface TagOverview { + tag: string + videos: Video[] +} + +export interface VideosOverview { + channels: ChannelOverview[] + + categories: CategoryOverview[] + + tags: TagOverview[] +} diff --git a/packages/models/src/plugins/client/client-hook.model.ts b/packages/models/src/plugins/client/client-hook.model.ts new file mode 100644 index 000000000..4a0818c99 --- /dev/null +++ b/packages/models/src/plugins/client/client-hook.model.ts @@ -0,0 +1,195 @@ +// Data from API hooks: {hookType}:api.{location}.{elementType}.{actionType}.{target} +// Data in internal functions: {hookType}:{location}.{elementType}.{actionType}.{target} + +export const clientFilterHookObject = { + // Filter params/result of the function that fetch videos of the trending page + 'filter:api.trending-videos.videos.list.params': true, + 'filter:api.trending-videos.videos.list.result': true, + + // Filter params/result of the function that fetch videos of the trending page + 'filter:api.most-liked-videos.videos.list.params': true, + 'filter:api.most-liked-videos.videos.list.result': true, + + // Filter params/result of the function that fetch videos of the local page + 'filter:api.local-videos.videos.list.params': true, + 'filter:api.local-videos.videos.list.result': true, + + // Filter params/result of the function that fetch videos of the recently-added page + 'filter:api.recently-added-videos.videos.list.params': true, + 'filter:api.recently-added-videos.videos.list.result': true, + + // Filter params/result of the function that fetch videos of the user subscription page + 'filter:api.user-subscriptions-videos.videos.list.params': true, + 'filter:api.user-subscriptions-videos.videos.list.result': true, + + // Filter params/result of the function that fetch the video of the video-watch page + 'filter:api.video-watch.video.get.params': true, + 'filter:api.video-watch.video.get.result': true, + + // Filter params/result of the function that fetch video playlist elements of the video-watch page + 'filter:api.video-watch.video-playlist-elements.get.params': true, + 'filter:api.video-watch.video-playlist-elements.get.result': true, + + // Filter params/result of the function that fetch the threads of the video-watch page + 'filter:api.video-watch.video-threads.list.params': true, + 'filter:api.video-watch.video-threads.list.result': true, + + // Filter params/result of the function that fetch the replies of a thread in the video-watch page + 'filter:api.video-watch.video-thread-replies.list.params': true, + 'filter:api.video-watch.video-thread-replies.list.result': true, + + // Filter params/result of the function that fetch videos according to the user search + 'filter:api.search.videos.list.params': true, + 'filter:api.search.videos.list.result': true, + // Filter params/result of the function that fetch video channels according to the user search + 'filter:api.search.video-channels.list.params': true, + 'filter:api.search.video-channels.list.result': true, + // Filter params/result of the function that fetch video playlists according to the user search + 'filter:api.search.video-playlists.list.params': true, + 'filter:api.search.video-playlists.list.result': true, + + // Filter form + 'filter:api.signup.registration.create.params': true, + + // Filter params/result of the function that fetch video playlist elements of the my-library page + 'filter:api.my-library.video-playlist-elements.list.params': true, + 'filter:api.my-library.video-playlist-elements.list.result': true, + + // Filter the options to create our player + 'filter:internal.video-watch.player.build-options.params': true, + 'filter:internal.video-watch.player.build-options.result': true, + + // Filter the options to load a new video in our player + 'filter:internal.video-watch.player.load-options.params': true, + 'filter:internal.video-watch.player.load-options.result': true, + + // Filter our SVG icons content + 'filter:internal.common.svg-icons.get-content.params': true, + 'filter:internal.common.svg-icons.get-content.result': true, + + // Filter left menu links + 'filter:left-menu.links.create.result': true, + + // Filter upload page alert messages + 'filter:upload.messages.create.result': true, + + 'filter:login.instance-about-plugin-panels.create.result': true, + 'filter:signup.instance-about-plugin-panels.create.result': true, + + 'filter:share.video-embed-code.build.params': true, + 'filter:share.video-embed-code.build.result': true, + 'filter:share.video-playlist-embed-code.build.params': true, + 'filter:share.video-playlist-embed-code.build.result': true, + + 'filter:share.video-embed-url.build.params': true, + 'filter:share.video-embed-url.build.result': true, + 'filter:share.video-playlist-embed-url.build.params': true, + 'filter:share.video-playlist-embed-url.build.result': true, + + 'filter:share.video-url.build.params': true, + 'filter:share.video-url.build.result': true, + 'filter:share.video-playlist-url.build.params': true, + 'filter:share.video-playlist-url.build.result': true, + + 'filter:video-watch.video-plugin-metadata.result': true, + + // Filter videojs options built for PeerTube player + 'filter:internal.player.videojs.options.result': true, + + // Filter p2p media loader options built for PeerTube player + 'filter:internal.player.p2p-media-loader.options.result': true +} + +export type ClientFilterHookName = keyof typeof clientFilterHookObject + +export const clientActionHookObject = { + // Fired when the application is being initialized + 'action:application.init': true, + + // Fired when the video watch page is being initialized + 'action:video-watch.init': true, + // Fired when the video watch page loaded the video + 'action:video-watch.video.loaded': true, + // Fired when the player finished loading + 'action:video-watch.player.loaded': true, + // Fired when the video watch page comments(threads) are loaded and load more comments on scroll + 'action:video-watch.video-threads.loaded': true, + // Fired when a user click on 'View x replies' and they're loaded + 'action:video-watch.video-thread-replies.loaded': true, + + // Fired when the video channel creation page is being initialized + 'action:video-channel-create.init': true, + + // Fired when the video channel update page is being initialized + 'action:video-channel-update.init': true, + 'action:video-channel-update.video-channel.loaded': true, + + // Fired when the page that list video channel videos is being initialized + 'action:video-channel-videos.init': true, + 'action:video-channel-videos.video-channel.loaded': true, + 'action:video-channel-videos.videos.loaded': true, + + // Fired when the page that list video channel playlists is being initialized + 'action:video-channel-playlists.init': true, + 'action:video-channel-playlists.video-channel.loaded': true, + 'action:video-channel-playlists.playlists.loaded': true, + + // Fired when the video edit page (upload, URL/torrent import, update) is being initialized + // Contains a `type` and `updateForm` object attributes + 'action:video-edit.init': true, + + // Fired when values of the video edit form changed + 'action:video-edit.form.updated': true, + + // Fired when the login page is being initialized + 'action:login.init': true, + + // Fired when the search page is being initialized + 'action:search.init': true, + + // Fired every time Angular URL changes + 'action:router.navigation-end': true, + + // Fired when the registration page is being initialized + 'action:signup.register.init': true, + + // PeerTube >= 3.2 + // Fired when the admin plugin settings page is being initialized + 'action:admin-plugin-settings.init': true, + + // Fired when the video upload page is being initialized + 'action:video-upload.init': true, + // Fired when the video import by URL page is being initialized + 'action:video-url-import.init': true, + // Fired when the video import by torrent/magnet URI page is being initialized + 'action:video-torrent-import.init': true, + // Fired when the "Go Live" page is being initialized + 'action:go-live.init': true, + + // Fired when the user explicitly logged in/logged out + 'action:auth-user.logged-in': true, + 'action:auth-user.logged-out': true, + // Fired when the application loaded user information (using tokens from the local storage or after a successful login) + 'action:auth-user.information-loaded': true, + + // Fired when the modal to download a video/caption is shown + 'action:modal.video-download.shown': true, + // Fired when the modal to share a video/playlist is shown + 'action:modal.share.shown': true, + + // ####### Embed hooks ####### + // /!\ In embed scope, peertube helpers are not available + // ########################### + + // Fired when the embed loaded the player + 'action:embed.player.loaded': true +} + +export type ClientActionHookName = keyof typeof clientActionHookObject + +export const clientHookObject = Object.assign({}, clientFilterHookObject, clientActionHookObject) +export type ClientHookName = keyof typeof clientHookObject + +export interface ClientHook { + runHook (hookName: ClientHookName, result?: T, params?: any): Promise +} diff --git a/packages/models/src/plugins/client/index.ts b/packages/models/src/plugins/client/index.ts new file mode 100644 index 000000000..04fa32d6d --- /dev/null +++ b/packages/models/src/plugins/client/index.ts @@ -0,0 +1,8 @@ +export * from './client-hook.model.js' +export * from './plugin-client-scope.type.js' +export * from './plugin-element-placeholder.type.js' +export * from './plugin-selector-id.type.js' +export * from './register-client-form-field.model.js' +export * from './register-client-hook.model.js' +export * from './register-client-route.model.js' +export * from './register-client-settings-script.model.js' diff --git a/packages/models/src/plugins/client/plugin-client-scope.type.ts b/packages/models/src/plugins/client/plugin-client-scope.type.ts new file mode 100644 index 000000000..c09a453b8 --- /dev/null +++ b/packages/models/src/plugins/client/plugin-client-scope.type.ts @@ -0,0 +1,11 @@ +export type PluginClientScope = + 'common' | + 'video-watch' | + 'search' | + 'signup' | + 'login' | + 'embed' | + 'video-edit' | + 'admin-plugin' | + 'my-library' | + 'video-channel' diff --git a/packages/models/src/plugins/client/plugin-element-placeholder.type.ts b/packages/models/src/plugins/client/plugin-element-placeholder.type.ts new file mode 100644 index 000000000..7b8a2605b --- /dev/null +++ b/packages/models/src/plugins/client/plugin-element-placeholder.type.ts @@ -0,0 +1,4 @@ +export type PluginElementPlaceholder = + 'player-next' | + 'share-modal-playlist-settings' | + 'share-modal-video-settings' diff --git a/packages/models/src/plugins/client/plugin-selector-id.type.ts b/packages/models/src/plugins/client/plugin-selector-id.type.ts new file mode 100644 index 000000000..8d23314b5 --- /dev/null +++ b/packages/models/src/plugins/client/plugin-selector-id.type.ts @@ -0,0 +1,10 @@ +export type PluginSelectorId = + 'login-form' | + 'menu-user-dropdown-language-item' | + 'about-instance-features' | + 'about-instance-statistics' | + 'about-instance-moderation' | + 'about-menu-instance' | + 'about-menu-peertube' | + 'about-menu-network' | + 'about-instance-other-information' diff --git a/packages/models/src/plugins/client/register-client-form-field.model.ts b/packages/models/src/plugins/client/register-client-form-field.model.ts new file mode 100644 index 000000000..153c4a6ea --- /dev/null +++ b/packages/models/src/plugins/client/register-client-form-field.model.ts @@ -0,0 +1,30 @@ +export type RegisterClientFormFieldOptions = { + name?: string + label?: string + type: 'input' | 'input-checkbox' | 'input-password' | 'input-textarea' | 'markdown-text' | 'markdown-enhanced' | 'select' | 'html' + + // For select type + options?: { value: string, label: string }[] + + // For html type + html?: string + + descriptionHTML?: string + + // Default setting value + default?: string | boolean + + // Not supported by plugin setting registration, use registerSettingsScript instead + hidden?: (options: any) => boolean + + // Return undefined | null if there is no error or return a string with the detailed error + // Not supported by plugin setting registration + error?: (options: any) => Promise<{ error: boolean, text?: string }> +} + +export interface RegisterClientVideoFieldOptions { + type: 'update' | 'upload' | 'import-url' | 'import-torrent' | 'go-live' + + // Default to 'plugin-settings' + tab?: 'main' | 'plugin-settings' +} diff --git a/packages/models/src/plugins/client/register-client-hook.model.ts b/packages/models/src/plugins/client/register-client-hook.model.ts new file mode 100644 index 000000000..19159ed1e --- /dev/null +++ b/packages/models/src/plugins/client/register-client-hook.model.ts @@ -0,0 +1,7 @@ +import { ClientHookName } from './client-hook.model.js' + +export interface RegisterClientHookOptions { + target: ClientHookName + handler: Function + priority?: number +} diff --git a/packages/models/src/plugins/client/register-client-route.model.ts b/packages/models/src/plugins/client/register-client-route.model.ts new file mode 100644 index 000000000..271b67834 --- /dev/null +++ b/packages/models/src/plugins/client/register-client-route.model.ts @@ -0,0 +1,7 @@ +export interface RegisterClientRouteOptions { + route: string + + onMount (options: { + rootEl: HTMLElement + }): void +} diff --git a/packages/models/src/plugins/client/register-client-settings-script.model.ts b/packages/models/src/plugins/client/register-client-settings-script.model.ts new file mode 100644 index 000000000..7de3c1c28 --- /dev/null +++ b/packages/models/src/plugins/client/register-client-settings-script.model.ts @@ -0,0 +1,8 @@ +import { RegisterServerSettingOptions } from '../server/index.js' + +export interface RegisterClientSettingsScriptOptions { + isSettingHidden (options: { + setting: RegisterServerSettingOptions + formValues: { [name: string]: any } + }): boolean +} diff --git a/packages/models/src/plugins/hook-type.enum.ts b/packages/models/src/plugins/hook-type.enum.ts new file mode 100644 index 000000000..7acc5f48a --- /dev/null +++ b/packages/models/src/plugins/hook-type.enum.ts @@ -0,0 +1,7 @@ +export const HookType = { + STATIC: 1, + ACTION: 2, + FILTER: 3 +} as const + +export type HookType_Type = typeof HookType[keyof typeof HookType] diff --git a/packages/models/src/plugins/index.ts b/packages/models/src/plugins/index.ts new file mode 100644 index 000000000..1117a946e --- /dev/null +++ b/packages/models/src/plugins/index.ts @@ -0,0 +1,6 @@ +export * from './client/index.js' +export * from './plugin-index/index.js' +export * from './server/index.js' +export * from './hook-type.enum.js' +export * from './plugin-package-json.model.js' +export * from './plugin.type.js' diff --git a/packages/models/src/plugins/plugin-index/index.ts b/packages/models/src/plugins/plugin-index/index.ts new file mode 100644 index 000000000..f53b88084 --- /dev/null +++ b/packages/models/src/plugins/plugin-index/index.ts @@ -0,0 +1,3 @@ +export * from './peertube-plugin-index-list.model.js' +export * from './peertube-plugin-index.model.js' +export * from './peertube-plugin-latest-version.model.js' diff --git a/packages/models/src/plugins/plugin-index/peertube-plugin-index-list.model.ts b/packages/models/src/plugins/plugin-index/peertube-plugin-index-list.model.ts new file mode 100644 index 000000000..98301bbc1 --- /dev/null +++ b/packages/models/src/plugins/plugin-index/peertube-plugin-index-list.model.ts @@ -0,0 +1,10 @@ +import { PluginType_Type } from '../plugin.type.js' + +export interface PeertubePluginIndexList { + start: number + count: number + sort: string + pluginType?: PluginType_Type + currentPeerTubeEngine?: string + search?: string +} diff --git a/packages/models/src/plugins/plugin-index/peertube-plugin-index.model.ts b/packages/models/src/plugins/plugin-index/peertube-plugin-index.model.ts new file mode 100644 index 000000000..36dfef943 --- /dev/null +++ b/packages/models/src/plugins/plugin-index/peertube-plugin-index.model.ts @@ -0,0 +1,16 @@ +export interface PeerTubePluginIndex { + npmName: string + description: string + homepage: string + createdAt: Date + updatedAt: Date + + popularity: number + + latestVersion: string + + official: boolean + + name?: string + installed?: boolean +} diff --git a/packages/models/src/plugins/plugin-index/peertube-plugin-latest-version.model.ts b/packages/models/src/plugins/plugin-index/peertube-plugin-latest-version.model.ts new file mode 100644 index 000000000..811a64429 --- /dev/null +++ b/packages/models/src/plugins/plugin-index/peertube-plugin-latest-version.model.ts @@ -0,0 +1,10 @@ +export interface PeertubePluginLatestVersionRequest { + currentPeerTubeEngine?: string + + npmNames: string[] +} + +export type PeertubePluginLatestVersionResponse = { + npmName: string + latestVersion: string | null +}[] diff --git a/packages/models/src/plugins/plugin-package-json.model.ts b/packages/models/src/plugins/plugin-package-json.model.ts new file mode 100644 index 000000000..5b9ccec56 --- /dev/null +++ b/packages/models/src/plugins/plugin-package-json.model.ts @@ -0,0 +1,29 @@ +import { PluginClientScope } from './client/plugin-client-scope.type.js' + +export type PluginTranslationPathsJSON = { + [ locale: string ]: string +} + +export type ClientScriptJSON = { + script: string + scopes: PluginClientScope[] +} + +export type PluginPackageJSON = { + name: string + version: string + description: string + engine: { peertube: string } + + homepage: string + author: string + bugs: string + library: string + + staticDirs: { [ name: string ]: string } + css: string[] + + clientScripts: ClientScriptJSON[] + + translations: PluginTranslationPathsJSON +} diff --git a/packages/models/src/plugins/plugin.type.ts b/packages/models/src/plugins/plugin.type.ts new file mode 100644 index 000000000..7d03012e6 --- /dev/null +++ b/packages/models/src/plugins/plugin.type.ts @@ -0,0 +1,6 @@ +export const PluginType = { + PLUGIN: 1, + THEME: 2 +} as const + +export type PluginType_Type = typeof PluginType[keyof typeof PluginType] diff --git a/packages/models/src/plugins/server/api/index.ts b/packages/models/src/plugins/server/api/index.ts new file mode 100644 index 000000000..1e3842c46 --- /dev/null +++ b/packages/models/src/plugins/server/api/index.ts @@ -0,0 +1,3 @@ +export * from './install-plugin.model.js' +export * from './manage-plugin.model.js' +export * from './peertube-plugin.model.js' diff --git a/packages/models/src/plugins/server/api/install-plugin.model.ts b/packages/models/src/plugins/server/api/install-plugin.model.ts new file mode 100644 index 000000000..a1d009a00 --- /dev/null +++ b/packages/models/src/plugins/server/api/install-plugin.model.ts @@ -0,0 +1,5 @@ +export interface InstallOrUpdatePlugin { + npmName?: string + pluginVersion?: string + path?: string +} diff --git a/packages/models/src/plugins/server/api/manage-plugin.model.ts b/packages/models/src/plugins/server/api/manage-plugin.model.ts new file mode 100644 index 000000000..612b3056c --- /dev/null +++ b/packages/models/src/plugins/server/api/manage-plugin.model.ts @@ -0,0 +1,3 @@ +export interface ManagePlugin { + npmName: string +} diff --git a/packages/models/src/plugins/server/api/peertube-plugin.model.ts b/packages/models/src/plugins/server/api/peertube-plugin.model.ts new file mode 100644 index 000000000..0bc1b095b --- /dev/null +++ b/packages/models/src/plugins/server/api/peertube-plugin.model.ts @@ -0,0 +1,16 @@ +import { PluginType_Type } from '../../plugin.type.js' + +export interface PeerTubePlugin { + name: string + type: PluginType_Type + latestVersion: string + version: string + enabled: boolean + uninstalled: boolean + peertubeEngine: string + description: string + homepage: string + settings: { [ name: string ]: string } + createdAt: Date + updatedAt: Date +} diff --git a/packages/models/src/plugins/server/index.ts b/packages/models/src/plugins/server/index.ts new file mode 100644 index 000000000..04412318b --- /dev/null +++ b/packages/models/src/plugins/server/index.ts @@ -0,0 +1,7 @@ +export * from './api/index.js' +export * from './managers/index.js' +export * from './settings/index.js' +export * from './plugin-constant-manager.model.js' +export * from './plugin-translation.model.js' +export * from './register-server-hook.model.js' +export * from './server-hook.model.js' diff --git a/packages/models/src/plugins/server/managers/index.ts b/packages/models/src/plugins/server/managers/index.ts new file mode 100644 index 000000000..2433dd9bf --- /dev/null +++ b/packages/models/src/plugins/server/managers/index.ts @@ -0,0 +1,9 @@ + +export * from './plugin-playlist-privacy-manager.model.js' +export * from './plugin-settings-manager.model.js' +export * from './plugin-storage-manager.model.js' +export * from './plugin-transcoding-manager.model.js' +export * from './plugin-video-category-manager.model.js' +export * from './plugin-video-language-manager.model.js' +export * from './plugin-video-licence-manager.model.js' +export * from './plugin-video-privacy-manager.model.js' diff --git a/packages/models/src/plugins/server/managers/plugin-playlist-privacy-manager.model.ts b/packages/models/src/plugins/server/managers/plugin-playlist-privacy-manager.model.ts new file mode 100644 index 000000000..212c910c5 --- /dev/null +++ b/packages/models/src/plugins/server/managers/plugin-playlist-privacy-manager.model.ts @@ -0,0 +1,12 @@ +import { VideoPlaylistPrivacyType } from '../../../videos/playlist/video-playlist-privacy.model.js' +import { ConstantManager } from '../plugin-constant-manager.model.js' + +export interface PluginPlaylistPrivacyManager extends ConstantManager { + /** + * PUBLIC = 1, + * UNLISTED = 2, + * PRIVATE = 3 + * @deprecated use `deleteConstant` instead + */ + deletePlaylistPrivacy: (privacyKey: VideoPlaylistPrivacyType) => boolean +} diff --git a/packages/models/src/plugins/server/managers/plugin-settings-manager.model.ts b/packages/models/src/plugins/server/managers/plugin-settings-manager.model.ts new file mode 100644 index 000000000..b628718dd --- /dev/null +++ b/packages/models/src/plugins/server/managers/plugin-settings-manager.model.ts @@ -0,0 +1,17 @@ +export type SettingValue = string | boolean + +export interface SettingEntries { + [settingName: string]: SettingValue +} + +export type SettingsChangeCallback = (settings: SettingEntries) => Promise + +export interface PluginSettingsManager { + getSetting: (name: string) => Promise + + getSettings: (names: string[]) => Promise + + setSetting: (name: string, value: SettingValue) => Promise + + onSettingsChange: (cb: SettingsChangeCallback) => void +} diff --git a/packages/models/src/plugins/server/managers/plugin-storage-manager.model.ts b/packages/models/src/plugins/server/managers/plugin-storage-manager.model.ts new file mode 100644 index 000000000..51567044a --- /dev/null +++ b/packages/models/src/plugins/server/managers/plugin-storage-manager.model.ts @@ -0,0 +1,5 @@ +export interface PluginStorageManager { + getData: (key: string) => Promise + + storeData: (key: string, data: any) => Promise +} diff --git a/packages/models/src/plugins/server/managers/plugin-transcoding-manager.model.ts b/packages/models/src/plugins/server/managers/plugin-transcoding-manager.model.ts new file mode 100644 index 000000000..235f5c65d --- /dev/null +++ b/packages/models/src/plugins/server/managers/plugin-transcoding-manager.model.ts @@ -0,0 +1,13 @@ +import { EncoderOptionsBuilder } from '../../../videos/transcoding/index.js' + +export interface PluginTranscodingManager { + addLiveProfile (encoder: string, profile: string, builder: EncoderOptionsBuilder): boolean + + addVODProfile (encoder: string, profile: string, builder: EncoderOptionsBuilder): boolean + + addLiveEncoderPriority (streamType: 'audio' | 'video', encoder: string, priority: number): void + + addVODEncoderPriority (streamType: 'audio' | 'video', encoder: string, priority: number): void + + removeAllProfilesAndEncoderPriorities(): void +} diff --git a/packages/models/src/plugins/server/managers/plugin-video-category-manager.model.ts b/packages/models/src/plugins/server/managers/plugin-video-category-manager.model.ts new file mode 100644 index 000000000..9da691e11 --- /dev/null +++ b/packages/models/src/plugins/server/managers/plugin-video-category-manager.model.ts @@ -0,0 +1,13 @@ +import { ConstantManager } from '../plugin-constant-manager.model.js' + +export interface PluginVideoCategoryManager extends ConstantManager { + /** + * @deprecated use `addConstant` instead + */ + addCategory: (categoryKey: number, categoryLabel: string) => boolean + + /** + * @deprecated use `deleteConstant` instead + */ + deleteCategory: (categoryKey: number) => boolean +} diff --git a/packages/models/src/plugins/server/managers/plugin-video-language-manager.model.ts b/packages/models/src/plugins/server/managers/plugin-video-language-manager.model.ts new file mode 100644 index 000000000..712486075 --- /dev/null +++ b/packages/models/src/plugins/server/managers/plugin-video-language-manager.model.ts @@ -0,0 +1,13 @@ +import { ConstantManager } from '../plugin-constant-manager.model.js' + +export interface PluginVideoLanguageManager extends ConstantManager { + /** + * @deprecated use `addConstant` instead + */ + addLanguage: (languageKey: string, languageLabel: string) => boolean + + /** + * @deprecated use `deleteConstant` instead + */ + deleteLanguage: (languageKey: string) => boolean +} diff --git a/packages/models/src/plugins/server/managers/plugin-video-licence-manager.model.ts b/packages/models/src/plugins/server/managers/plugin-video-licence-manager.model.ts new file mode 100644 index 000000000..cebae8d95 --- /dev/null +++ b/packages/models/src/plugins/server/managers/plugin-video-licence-manager.model.ts @@ -0,0 +1,13 @@ +import { ConstantManager } from '../plugin-constant-manager.model.js' + +export interface PluginVideoLicenceManager extends ConstantManager { + /** + * @deprecated use `addConstant` instead + */ + addLicence: (licenceKey: number, licenceLabel: string) => boolean + + /** + * @deprecated use `deleteConstant` instead + */ + deleteLicence: (licenceKey: number) => boolean +} diff --git a/packages/models/src/plugins/server/managers/plugin-video-privacy-manager.model.ts b/packages/models/src/plugins/server/managers/plugin-video-privacy-manager.model.ts new file mode 100644 index 000000000..260cee683 --- /dev/null +++ b/packages/models/src/plugins/server/managers/plugin-video-privacy-manager.model.ts @@ -0,0 +1,13 @@ +import { VideoPrivacyType } from '../../../videos/video-privacy.enum.js' +import { ConstantManager } from '../plugin-constant-manager.model.js' + +export interface PluginVideoPrivacyManager extends ConstantManager { + /** + * PUBLIC = 1, + * UNLISTED = 2, + * PRIVATE = 3 + * INTERNAL = 4 + * @deprecated use `deleteConstant` instead + */ + deletePrivacy: (privacyKey: VideoPrivacyType) => boolean +} diff --git a/packages/models/src/plugins/server/plugin-constant-manager.model.ts b/packages/models/src/plugins/server/plugin-constant-manager.model.ts new file mode 100644 index 000000000..4de3ce38f --- /dev/null +++ b/packages/models/src/plugins/server/plugin-constant-manager.model.ts @@ -0,0 +1,7 @@ +export interface ConstantManager { + addConstant: (key: K, label: string) => boolean + deleteConstant: (key: K) => boolean + getConstantValue: (key: K) => string + getConstants: () => Record + resetConstants: () => void +} diff --git a/packages/models/src/plugins/server/plugin-translation.model.ts b/packages/models/src/plugins/server/plugin-translation.model.ts new file mode 100644 index 000000000..a2dd8e560 --- /dev/null +++ b/packages/models/src/plugins/server/plugin-translation.model.ts @@ -0,0 +1,5 @@ +export type PluginTranslation = { + [ npmName: string ]: { + [ key: string ]: string + } +} diff --git a/packages/models/src/plugins/server/register-server-hook.model.ts b/packages/models/src/plugins/server/register-server-hook.model.ts new file mode 100644 index 000000000..05c883f1f --- /dev/null +++ b/packages/models/src/plugins/server/register-server-hook.model.ts @@ -0,0 +1,7 @@ +import { ServerHookName } from './server-hook.model.js' + +export interface RegisterServerHookOptions { + target: ServerHookName + handler: Function + priority?: number +} diff --git a/packages/models/src/plugins/server/server-hook.model.ts b/packages/models/src/plugins/server/server-hook.model.ts new file mode 100644 index 000000000..cf387ffd7 --- /dev/null +++ b/packages/models/src/plugins/server/server-hook.model.ts @@ -0,0 +1,221 @@ +// {hookType}:{root}.{location}.{subLocation?}.{actionType}.{target} + +export const serverFilterHookObject = { + // Filter params/result used to list videos for the REST API + // (used by the trending page, recently-added page, local page etc) + 'filter:api.videos.list.params': true, + 'filter:api.videos.list.result': true, + + // Filter params/result used to list a video playlists videos + // for the REST API + 'filter:api.video-playlist.videos.list.params': true, + 'filter:api.video-playlist.videos.list.result': true, + + // Filter params/result used to list account videos for the REST API + 'filter:api.accounts.videos.list.params': true, + 'filter:api.accounts.videos.list.result': true, + + // Filter params/result used to list channel videos for the REST API + 'filter:api.video-channels.videos.list.params': true, + 'filter:api.video-channels.videos.list.result': true, + + // Filter params/result used to list my user videos for the REST API + 'filter:api.user.me.videos.list.params': true, + 'filter:api.user.me.videos.list.result': true, + + // Filter params/result used to list overview videos for the REST API + 'filter:api.overviews.videos.list.params': true, + 'filter:api.overviews.videos.list.result': true, + + // Filter params/result used to list subscription videos for the REST API + 'filter:api.user.me.subscription-videos.list.params': true, + 'filter:api.user.me.subscription-videos.list.result': true, + + // Filter params/results to search videos/channels in the DB or on the remote index + 'filter:api.search.videos.local.list.params': true, + 'filter:api.search.videos.local.list.result': true, + 'filter:api.search.videos.index.list.params': true, + 'filter:api.search.videos.index.list.result': true, + 'filter:api.search.video-channels.local.list.params': true, + 'filter:api.search.video-channels.local.list.result': true, + 'filter:api.search.video-channels.index.list.params': true, + 'filter:api.search.video-channels.index.list.result': true, + 'filter:api.search.video-playlists.local.list.params': true, + 'filter:api.search.video-playlists.local.list.result': true, + 'filter:api.search.video-playlists.index.list.params': true, + 'filter:api.search.video-playlists.index.list.result': true, + + // Filter the result of the get function + // Used to get detailed video information (video watch page for example) + 'filter:api.video.get.result': true, + + // Filter params/results when listing video channels + 'filter:api.video-channels.list.params': true, + 'filter:api.video-channels.list.result': true, + + // Filter the result when getting a video channel + 'filter:api.video-channel.get.result': true, + + // Filter the result of the accept upload/live, import via torrent/url functions + // If this function returns false then the upload is aborted with an error + 'filter:api.video.upload.accept.result': true, + 'filter:api.live-video.create.accept.result': true, + 'filter:api.video.pre-import-url.accept.result': true, + 'filter:api.video.pre-import-torrent.accept.result': true, + 'filter:api.video.post-import-url.accept.result': true, + 'filter:api.video.post-import-torrent.accept.result': true, + 'filter:api.video.update-file.accept.result': true, + // Filter the result of the accept comment (thread or reply) functions + // If the functions return false then the user cannot post its comment + 'filter:api.video-thread.create.accept.result': true, + 'filter:api.video-comment-reply.create.accept.result': true, + + // Filter attributes when creating video object + 'filter:api.video.upload.video-attribute.result': true, + 'filter:api.video.import-url.video-attribute.result': true, + 'filter:api.video.import-torrent.video-attribute.result': true, + 'filter:api.video.live.video-attribute.result': true, + + // Filter params/result used to list threads of a specific video + // (used by the video watch page) + 'filter:api.video-threads.list.params': true, + 'filter:api.video-threads.list.result': true, + + // Filter params/result used to list replies of a specific thread + // (used by the video watch page when we click on the "View replies" button) + 'filter:api.video-thread-comments.list.params': true, + 'filter:api.video-thread-comments.list.result': true, + + // Filter get stats result + 'filter:api.server.stats.get.result': true, + + // Filter result used to check if we need to auto blacklist a video + // (fired when a local or remote video is created or updated) + 'filter:video.auto-blacklist.result': true, + + // Filter result used to check if a user can register on the instance + 'filter:api.user.signup.allowed.result': true, + + // Filter result used to check if a user can send a registration request on the instance + // PeerTube >= 5.1 + 'filter:api.user.request-signup.allowed.result': true, + + // Filter result used to check if video/torrent download is allowed + 'filter:api.download.video.allowed.result': true, + 'filter:api.download.torrent.allowed.result': true, + + // Filter result to check if the embed is allowed for a particular request + 'filter:html.embed.video.allowed.result': true, + 'filter:html.embed.video-playlist.allowed.result': true, + + // Peertube >= 5.2 + 'filter:html.client.json-ld.result': true, + + 'filter:job-queue.process.params': true, + 'filter:job-queue.process.result': true, + + 'filter:transcoding.manual.resolutions-to-transcode.result': true, + 'filter:transcoding.auto.resolutions-to-transcode.result': true, + + 'filter:activity-pub.remote-video-comment.create.accept.result': true, + + 'filter:activity-pub.activity.context.build.result': true, + + // Filter the result of video JSON LD builder + // You may also need to use filter:activity-pub.activity.context.build.result to also update JSON LD context + 'filter:activity-pub.video.json-ld.build.result': true, + + // Filter result to allow custom XMLNS definitions in podcast RSS feeds + // Peertube >= 5.2 + 'filter:feed.podcast.rss.create-custom-xmlns.result': true, + + // Filter result to allow custom tags in podcast RSS feeds + // Peertube >= 5.2 + 'filter:feed.podcast.channel.create-custom-tags.result': true, + // Peertube >= 5.2 + 'filter:feed.podcast.video.create-custom-tags.result': true +} + +export type ServerFilterHookName = keyof typeof serverFilterHookObject + +export const serverActionHookObject = { + // Fired when the application has been loaded and is listening HTTP requests + 'action:application.listening': true, + + // Fired when a new notification is created + 'action:notifier.notification.created': true, + + // API actions hooks give access to the original express `req` and `res` parameters + + // Fired when a local video is updated + 'action:api.video.updated': true, + // Fired when a local video is deleted + 'action:api.video.deleted': true, + // Fired when a local video is uploaded + 'action:api.video.uploaded': true, + // Fired when a local video is viewed + 'action:api.video.viewed': true, + + // Fired when a local video file has been replaced by a new one + 'action:api.video.file-updated': true, + + // Fired when a video channel is created + 'action:api.video-channel.created': true, + // Fired when a video channel is updated + 'action:api.video-channel.updated': true, + // Fired when a video channel is deleted + 'action:api.video-channel.deleted': true, + + // Fired when a live video is created + 'action:api.live-video.created': true, + // Fired when a live video starts or ends + // Peertube >= 5.2 + 'action:live.video.state.updated': true, + + // Fired when a thread is created + 'action:api.video-thread.created': true, + // Fired when a reply to a thread is created + 'action:api.video-comment-reply.created': true, + // Fired when a comment (thread or reply) is deleted + 'action:api.video-comment.deleted': true, + + // Fired when a caption is created + 'action:api.video-caption.created': true, + // Fired when a caption is deleted + 'action:api.video-caption.deleted': true, + + // Fired when a user is blocked (banned) + 'action:api.user.blocked': true, + // Fired when a user is unblocked (unbanned) + 'action:api.user.unblocked': true, + // Fired when a user registered on the instance + 'action:api.user.registered': true, + // Fired when a user requested registration on the instance + // PeerTube >= 5.1 + 'action:api.user.requested-registration': true, + // Fired when an admin/moderator created a user + 'action:api.user.created': true, + // Fired when a user is removed by an admin/moderator + 'action:api.user.deleted': true, + // Fired when a user is updated by an admin/moderator + 'action:api.user.updated': true, + + // Fired when a user got a new oauth2 token + 'action:api.user.oauth2-got-token': true, + + // Fired when a video is added to a playlist + 'action:api.video-playlist-element.created': true, + + // Fired when a remote video has been created/updated + 'action:activity-pub.remote-video.created': true, + 'action:activity-pub.remote-video.updated': true +} + +export type ServerActionHookName = keyof typeof serverActionHookObject + +export const serverHookObject = Object.assign({}, serverFilterHookObject, serverActionHookObject) +export type ServerHookName = keyof typeof serverHookObject + +export interface ServerHook { + runHook (hookName: ServerHookName, result?: T, params?: any): Promise +} diff --git a/packages/models/src/plugins/server/settings/index.ts b/packages/models/src/plugins/server/settings/index.ts new file mode 100644 index 000000000..4bdccaa4a --- /dev/null +++ b/packages/models/src/plugins/server/settings/index.ts @@ -0,0 +1,2 @@ +export * from './public-server.setting.js' +export * from './register-server-setting.model.js' diff --git a/packages/models/src/plugins/server/settings/public-server.setting.ts b/packages/models/src/plugins/server/settings/public-server.setting.ts new file mode 100644 index 000000000..0b6251aa3 --- /dev/null +++ b/packages/models/src/plugins/server/settings/public-server.setting.ts @@ -0,0 +1,5 @@ +import { SettingEntries } from '../managers/plugin-settings-manager.model.js' + +export interface PublicServerSetting { + publicSettings: SettingEntries +} diff --git a/packages/models/src/plugins/server/settings/register-server-setting.model.ts b/packages/models/src/plugins/server/settings/register-server-setting.model.ts new file mode 100644 index 000000000..8cde8eaaa --- /dev/null +++ b/packages/models/src/plugins/server/settings/register-server-setting.model.ts @@ -0,0 +1,12 @@ +import { RegisterClientFormFieldOptions } from '../../client/index.js' + +export type RegisterServerSettingOptions = RegisterClientFormFieldOptions & { + // If the setting is not private, anyone can view its value (client code included) + // If the setting is private, only server-side hooks can access it + // Mainly used by the PeerTube client to get admin config + private: boolean +} + +export interface RegisteredServerSettings { + registeredSettings: RegisterServerSettingOptions[] +} diff --git a/packages/models/src/redundancy/index.ts b/packages/models/src/redundancy/index.ts new file mode 100644 index 000000000..89e8fe464 --- /dev/null +++ b/packages/models/src/redundancy/index.ts @@ -0,0 +1,4 @@ +export * from './video-redundancies-filters.model.js' +export * from './video-redundancy-config-filter.type.js' +export * from './video-redundancy.model.js' +export * from './videos-redundancy-strategy.model.js' diff --git a/packages/models/src/redundancy/video-redundancies-filters.model.ts b/packages/models/src/redundancy/video-redundancies-filters.model.ts new file mode 100644 index 000000000..05ba7dfd3 --- /dev/null +++ b/packages/models/src/redundancy/video-redundancies-filters.model.ts @@ -0,0 +1 @@ +export type VideoRedundanciesTarget = 'my-videos' | 'remote-videos' diff --git a/packages/models/src/redundancy/video-redundancy-config-filter.type.ts b/packages/models/src/redundancy/video-redundancy-config-filter.type.ts new file mode 100644 index 000000000..bb1ae701c --- /dev/null +++ b/packages/models/src/redundancy/video-redundancy-config-filter.type.ts @@ -0,0 +1 @@ +export type VideoRedundancyConfigFilter = 'nobody' | 'anybody' | 'followings' diff --git a/packages/models/src/redundancy/video-redundancy.model.ts b/packages/models/src/redundancy/video-redundancy.model.ts new file mode 100644 index 000000000..fa6e05832 --- /dev/null +++ b/packages/models/src/redundancy/video-redundancy.model.ts @@ -0,0 +1,35 @@ +export interface VideoRedundancy { + id: number + name: string + url: string + uuid: string + + redundancies: { + files: FileRedundancyInformation[] + + streamingPlaylists: StreamingPlaylistRedundancyInformation[] + } +} + +interface RedundancyInformation { + id: number + fileUrl: string + strategy: string + + createdAt: Date | string + updatedAt: Date | string + + expiresOn: Date | string + + size: number +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface FileRedundancyInformation extends RedundancyInformation { + +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface StreamingPlaylistRedundancyInformation extends RedundancyInformation { + +} diff --git a/packages/models/src/redundancy/videos-redundancy-strategy.model.ts b/packages/models/src/redundancy/videos-redundancy-strategy.model.ts new file mode 100644 index 000000000..15409abf0 --- /dev/null +++ b/packages/models/src/redundancy/videos-redundancy-strategy.model.ts @@ -0,0 +1,23 @@ +export type VideoRedundancyStrategy = 'most-views' | 'trending' | 'recently-added' +export type VideoRedundancyStrategyWithManual = VideoRedundancyStrategy | 'manual' + +export type MostViewsRedundancyStrategy = { + strategy: 'most-views' + size: number + minLifetime: number +} + +export type TrendingRedundancyStrategy = { + strategy: 'trending' + size: number + minLifetime: number +} + +export type RecentlyAddedStrategy = { + strategy: 'recently-added' + size: number + minViews: number + minLifetime: number +} + +export type VideosRedundancyStrategy = MostViewsRedundancyStrategy | TrendingRedundancyStrategy | RecentlyAddedStrategy diff --git a/packages/models/src/runners/abort-runner-job-body.model.ts b/packages/models/src/runners/abort-runner-job-body.model.ts new file mode 100644 index 000000000..0b9c46c91 --- /dev/null +++ b/packages/models/src/runners/abort-runner-job-body.model.ts @@ -0,0 +1,6 @@ +export interface AbortRunnerJobBody { + runnerToken: string + jobToken: string + + reason: string +} diff --git a/packages/models/src/runners/accept-runner-job-body.model.ts b/packages/models/src/runners/accept-runner-job-body.model.ts new file mode 100644 index 000000000..cb266c4e6 --- /dev/null +++ b/packages/models/src/runners/accept-runner-job-body.model.ts @@ -0,0 +1,3 @@ +export interface AcceptRunnerJobBody { + runnerToken: string +} diff --git a/packages/models/src/runners/accept-runner-job-result.model.ts b/packages/models/src/runners/accept-runner-job-result.model.ts new file mode 100644 index 000000000..ebb605930 --- /dev/null +++ b/packages/models/src/runners/accept-runner-job-result.model.ts @@ -0,0 +1,6 @@ +import { RunnerJobPayload } from './runner-job-payload.model.js' +import { RunnerJob } from './runner-job.model.js' + +export interface AcceptRunnerJobResult { + job: RunnerJob & { jobToken: string } +} diff --git a/packages/models/src/runners/error-runner-job-body.model.ts b/packages/models/src/runners/error-runner-job-body.model.ts new file mode 100644 index 000000000..ac8568409 --- /dev/null +++ b/packages/models/src/runners/error-runner-job-body.model.ts @@ -0,0 +1,6 @@ +export interface ErrorRunnerJobBody { + runnerToken: string + jobToken: string + + message: string +} diff --git a/packages/models/src/runners/index.ts b/packages/models/src/runners/index.ts new file mode 100644 index 000000000..cfe997b64 --- /dev/null +++ b/packages/models/src/runners/index.ts @@ -0,0 +1,21 @@ +export * from './abort-runner-job-body.model.js' +export * from './accept-runner-job-body.model.js' +export * from './accept-runner-job-result.model.js' +export * from './error-runner-job-body.model.js' +export * from './list-runner-jobs-query.model.js' +export * from './list-runner-registration-tokens.model.js' +export * from './list-runners-query.model.js' +export * from './register-runner-body.model.js' +export * from './register-runner-result.model.js' +export * from './request-runner-job-body.model.js' +export * from './request-runner-job-result.model.js' +export * from './runner-job-payload.model.js' +export * from './runner-job-private-payload.model.js' +export * from './runner-job-state.model.js' +export * from './runner-job-success-body.model.js' +export * from './runner-job-type.type.js' +export * from './runner-job-update-body.model.js' +export * from './runner-job.model.js' +export * from './runner-registration-token.js' +export * from './runner.model.js' +export * from './unregister-runner-body.model.js' diff --git a/packages/models/src/runners/list-runner-jobs-query.model.ts b/packages/models/src/runners/list-runner-jobs-query.model.ts new file mode 100644 index 000000000..395fe4b92 --- /dev/null +++ b/packages/models/src/runners/list-runner-jobs-query.model.ts @@ -0,0 +1,9 @@ +import { RunnerJobStateType } from './runner-job-state.model.js' + +export interface ListRunnerJobsQuery { + start?: number + count?: number + sort?: string + search?: string + stateOneOf?: RunnerJobStateType[] +} diff --git a/packages/models/src/runners/list-runner-registration-tokens.model.ts b/packages/models/src/runners/list-runner-registration-tokens.model.ts new file mode 100644 index 000000000..872e059cf --- /dev/null +++ b/packages/models/src/runners/list-runner-registration-tokens.model.ts @@ -0,0 +1,5 @@ +export interface ListRunnerRegistrationTokensQuery { + start?: number + count?: number + sort?: string +} diff --git a/packages/models/src/runners/list-runners-query.model.ts b/packages/models/src/runners/list-runners-query.model.ts new file mode 100644 index 000000000..d4362e4c5 --- /dev/null +++ b/packages/models/src/runners/list-runners-query.model.ts @@ -0,0 +1,5 @@ +export interface ListRunnersQuery { + start?: number + count?: number + sort?: string +} diff --git a/packages/models/src/runners/register-runner-body.model.ts b/packages/models/src/runners/register-runner-body.model.ts new file mode 100644 index 000000000..969bb35e1 --- /dev/null +++ b/packages/models/src/runners/register-runner-body.model.ts @@ -0,0 +1,6 @@ +export interface RegisterRunnerBody { + registrationToken: string + + name: string + description?: string +} diff --git a/packages/models/src/runners/register-runner-result.model.ts b/packages/models/src/runners/register-runner-result.model.ts new file mode 100644 index 000000000..e31776c6a --- /dev/null +++ b/packages/models/src/runners/register-runner-result.model.ts @@ -0,0 +1,4 @@ +export interface RegisterRunnerResult { + id: number + runnerToken: string +} diff --git a/packages/models/src/runners/request-runner-job-body.model.ts b/packages/models/src/runners/request-runner-job-body.model.ts new file mode 100644 index 000000000..0970d9007 --- /dev/null +++ b/packages/models/src/runners/request-runner-job-body.model.ts @@ -0,0 +1,3 @@ +export interface RequestRunnerJobBody { + runnerToken: string +} diff --git a/packages/models/src/runners/request-runner-job-result.model.ts b/packages/models/src/runners/request-runner-job-result.model.ts new file mode 100644 index 000000000..30c8c640c --- /dev/null +++ b/packages/models/src/runners/request-runner-job-result.model.ts @@ -0,0 +1,10 @@ +import { RunnerJobPayload } from './runner-job-payload.model.js' +import { RunnerJobType } from './runner-job-type.type.js' + +export interface RequestRunnerJobResult

{ + availableJobs: { + uuid: string + type: RunnerJobType + payload: P + }[] +} diff --git a/packages/models/src/runners/runner-job-payload.model.ts b/packages/models/src/runners/runner-job-payload.model.ts new file mode 100644 index 000000000..19b9d649b --- /dev/null +++ b/packages/models/src/runners/runner-job-payload.model.ts @@ -0,0 +1,79 @@ +import { VideoStudioTaskPayload } from '../server/index.js' + +export type RunnerJobVODPayload = + RunnerJobVODWebVideoTranscodingPayload | + RunnerJobVODHLSTranscodingPayload | + RunnerJobVODAudioMergeTranscodingPayload + +export type RunnerJobPayload = + RunnerJobVODPayload | + RunnerJobLiveRTMPHLSTranscodingPayload | + RunnerJobStudioTranscodingPayload + +// --------------------------------------------------------------------------- + +export interface RunnerJobVODWebVideoTranscodingPayload { + input: { + videoFileUrl: string + } + + output: { + resolution: number + fps: number + } +} + +export interface RunnerJobVODHLSTranscodingPayload { + input: { + videoFileUrl: string + } + + output: { + resolution: number + fps: number + } +} + +export interface RunnerJobVODAudioMergeTranscodingPayload { + input: { + audioFileUrl: string + previewFileUrl: string + } + + output: { + resolution: number + fps: number + } +} + +export interface RunnerJobStudioTranscodingPayload { + input: { + videoFileUrl: string + } + + tasks: VideoStudioTaskPayload[] +} + +// --------------------------------------------------------------------------- + +export function isAudioMergeTranscodingPayload (payload: RunnerJobPayload): payload is RunnerJobVODAudioMergeTranscodingPayload { + return !!(payload as RunnerJobVODAudioMergeTranscodingPayload).input.audioFileUrl +} + +// --------------------------------------------------------------------------- + +export interface RunnerJobLiveRTMPHLSTranscodingPayload { + input: { + rtmpUrl: string + } + + output: { + toTranscode: { + resolution: number + fps: number + }[] + + segmentDuration: number + segmentListSize: number + } +} diff --git a/packages/models/src/runners/runner-job-private-payload.model.ts b/packages/models/src/runners/runner-job-private-payload.model.ts new file mode 100644 index 000000000..c1205984e --- /dev/null +++ b/packages/models/src/runners/runner-job-private-payload.model.ts @@ -0,0 +1,45 @@ +import { VideoStudioTaskPayload } from '../server/index.js' + +export type RunnerJobVODPrivatePayload = + RunnerJobVODWebVideoTranscodingPrivatePayload | + RunnerJobVODAudioMergeTranscodingPrivatePayload | + RunnerJobVODHLSTranscodingPrivatePayload + +export type RunnerJobPrivatePayload = + RunnerJobVODPrivatePayload | + RunnerJobLiveRTMPHLSTranscodingPrivatePayload | + RunnerJobVideoStudioTranscodingPrivatePayload + +// --------------------------------------------------------------------------- + +export interface RunnerJobVODWebVideoTranscodingPrivatePayload { + videoUUID: string + isNewVideo: boolean +} + +export interface RunnerJobVODAudioMergeTranscodingPrivatePayload { + videoUUID: string + isNewVideo: boolean +} + +export interface RunnerJobVODHLSTranscodingPrivatePayload { + videoUUID: string + isNewVideo: boolean + deleteWebVideoFiles: boolean +} + +// --------------------------------------------------------------------------- + +export interface RunnerJobLiveRTMPHLSTranscodingPrivatePayload { + videoUUID: string + masterPlaylistName: string + outputDirectory: string + sessionId: string +} + +// --------------------------------------------------------------------------- + +export interface RunnerJobVideoStudioTranscodingPrivatePayload { + videoUUID: string + originalTasks: VideoStudioTaskPayload[] +} diff --git a/packages/models/src/runners/runner-job-state.model.ts b/packages/models/src/runners/runner-job-state.model.ts new file mode 100644 index 000000000..07e135121 --- /dev/null +++ b/packages/models/src/runners/runner-job-state.model.ts @@ -0,0 +1,13 @@ +export const RunnerJobState = { + PENDING: 1, + PROCESSING: 2, + COMPLETED: 3, + ERRORED: 4, + WAITING_FOR_PARENT_JOB: 5, + CANCELLED: 6, + PARENT_ERRORED: 7, + PARENT_CANCELLED: 8, + COMPLETING: 9 +} as const + +export type RunnerJobStateType = typeof RunnerJobState[keyof typeof RunnerJobState] diff --git a/packages/models/src/runners/runner-job-success-body.model.ts b/packages/models/src/runners/runner-job-success-body.model.ts new file mode 100644 index 000000000..f45336b05 --- /dev/null +++ b/packages/models/src/runners/runner-job-success-body.model.ts @@ -0,0 +1,46 @@ +export interface RunnerJobSuccessBody { + runnerToken: string + jobToken: string + + payload: RunnerJobSuccessPayload +} + +// --------------------------------------------------------------------------- + +export type RunnerJobSuccessPayload = + VODWebVideoTranscodingSuccess | + VODHLSTranscodingSuccess | + VODAudioMergeTranscodingSuccess | + LiveRTMPHLSTranscodingSuccess | + VideoStudioTranscodingSuccess + +export interface VODWebVideoTranscodingSuccess { + videoFile: Blob | string +} + +export interface VODHLSTranscodingSuccess { + videoFile: Blob | string + resolutionPlaylistFile: Blob | string +} + +export interface VODAudioMergeTranscodingSuccess { + videoFile: Blob | string +} + +export interface LiveRTMPHLSTranscodingSuccess { + +} + +export interface VideoStudioTranscodingSuccess { + videoFile: Blob | string +} + +export function isWebVideoOrAudioMergeTranscodingPayloadSuccess ( + payload: RunnerJobSuccessPayload +): payload is VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess { + return !!(payload as VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess)?.videoFile +} + +export function isHLSTranscodingPayloadSuccess (payload: RunnerJobSuccessPayload): payload is VODHLSTranscodingSuccess { + return !!(payload as VODHLSTranscodingSuccess)?.resolutionPlaylistFile +} diff --git a/packages/models/src/runners/runner-job-type.type.ts b/packages/models/src/runners/runner-job-type.type.ts new file mode 100644 index 000000000..91b92a729 --- /dev/null +++ b/packages/models/src/runners/runner-job-type.type.ts @@ -0,0 +1,6 @@ +export type RunnerJobType = + 'vod-web-video-transcoding' | + 'vod-hls-transcoding' | + 'vod-audio-merge-transcoding' | + 'live-rtmp-hls-transcoding' | + 'video-studio-transcoding' diff --git a/packages/models/src/runners/runner-job-update-body.model.ts b/packages/models/src/runners/runner-job-update-body.model.ts new file mode 100644 index 000000000..ed94bbe63 --- /dev/null +++ b/packages/models/src/runners/runner-job-update-body.model.ts @@ -0,0 +1,28 @@ +export interface RunnerJobUpdateBody { + runnerToken: string + jobToken: string + + progress?: number + payload?: RunnerJobUpdatePayload +} + +// --------------------------------------------------------------------------- + +export type RunnerJobUpdatePayload = LiveRTMPHLSTranscodingUpdatePayload + +export interface LiveRTMPHLSTranscodingUpdatePayload { + type: 'add-chunk' | 'remove-chunk' + + masterPlaylistFile?: Blob | string + + resolutionPlaylistFilename?: string + resolutionPlaylistFile?: Blob | string + + videoChunkFilename: string + videoChunkFile?: Blob | string +} + +export function isLiveRTMPHLSTranscodingUpdatePayload (value: RunnerJobUpdatePayload): value is LiveRTMPHLSTranscodingUpdatePayload { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return !!(value as LiveRTMPHLSTranscodingUpdatePayload)?.videoChunkFilename +} diff --git a/packages/models/src/runners/runner-job.model.ts b/packages/models/src/runners/runner-job.model.ts new file mode 100644 index 000000000..6d6427396 --- /dev/null +++ b/packages/models/src/runners/runner-job.model.ts @@ -0,0 +1,45 @@ +import { VideoConstant } from '../videos/index.js' +import { RunnerJobPayload } from './runner-job-payload.model.js' +import { RunnerJobPrivatePayload } from './runner-job-private-payload.model.js' +import { RunnerJobStateType } from './runner-job-state.model.js' +import { RunnerJobType } from './runner-job-type.type.js' + +export interface RunnerJob { + uuid: string + + type: RunnerJobType + + state: VideoConstant + + payload: T + + failures: number + error: string | null + + progress: number + priority: number + + startedAt: Date | string + createdAt: Date | string + updatedAt: Date | string + finishedAt: Date | string + + parent?: { + type: RunnerJobType + state: VideoConstant + uuid: string + } + + // If associated to a runner + runner?: { + id: number + name: string + + description: string + } +} + +// eslint-disable-next-line max-len +export interface RunnerJobAdmin extends RunnerJob { + privatePayload: U +} diff --git a/packages/models/src/runners/runner-registration-token.ts b/packages/models/src/runners/runner-registration-token.ts new file mode 100644 index 000000000..0a157aa51 --- /dev/null +++ b/packages/models/src/runners/runner-registration-token.ts @@ -0,0 +1,10 @@ +export interface RunnerRegistrationToken { + id: number + + registrationToken: string + + createdAt: Date + updatedAt: Date + + registeredRunnersCount: number +} diff --git a/packages/models/src/runners/runner.model.ts b/packages/models/src/runners/runner.model.ts new file mode 100644 index 000000000..3284f2992 --- /dev/null +++ b/packages/models/src/runners/runner.model.ts @@ -0,0 +1,12 @@ +export interface Runner { + id: number + + name: string + description: string + + ip: string + lastContact: Date | string + + createdAt: Date | string + updatedAt: Date | string +} diff --git a/packages/models/src/runners/unregister-runner-body.model.ts b/packages/models/src/runners/unregister-runner-body.model.ts new file mode 100644 index 000000000..d3465c5d6 --- /dev/null +++ b/packages/models/src/runners/unregister-runner-body.model.ts @@ -0,0 +1,3 @@ +export interface UnregisterRunnerBody { + runnerToken: string +} diff --git a/packages/models/src/search/boolean-both-query.model.ts b/packages/models/src/search/boolean-both-query.model.ts new file mode 100644 index 000000000..d6a438249 --- /dev/null +++ b/packages/models/src/search/boolean-both-query.model.ts @@ -0,0 +1,2 @@ +export type BooleanBothQuery = 'true' | 'false' | 'both' +export type BooleanQuery = 'true' | 'false' diff --git a/packages/models/src/search/index.ts b/packages/models/src/search/index.ts new file mode 100644 index 000000000..5c4de1eea --- /dev/null +++ b/packages/models/src/search/index.ts @@ -0,0 +1,6 @@ +export * from './boolean-both-query.model.js' +export * from './search-target-query.model.js' +export * from './videos-common-query.model.js' +export * from './video-channels-search-query.model.js' +export * from './video-playlists-search-query.model.js' +export * from './videos-search-query.model.js' diff --git a/packages/models/src/search/search-target-query.model.ts b/packages/models/src/search/search-target-query.model.ts new file mode 100644 index 000000000..3bb2e0d31 --- /dev/null +++ b/packages/models/src/search/search-target-query.model.ts @@ -0,0 +1,5 @@ +export type SearchTargetType = 'local' | 'search-index' + +export interface SearchTargetQuery { + searchTarget?: SearchTargetType +} diff --git a/packages/models/src/search/video-channels-search-query.model.ts b/packages/models/src/search/video-channels-search-query.model.ts new file mode 100644 index 000000000..7e84359cf --- /dev/null +++ b/packages/models/src/search/video-channels-search-query.model.ts @@ -0,0 +1,18 @@ +import { SearchTargetQuery } from './search-target-query.model.js' + +export interface VideoChannelsSearchQuery extends SearchTargetQuery { + search?: string + + start?: number + count?: number + sort?: string + + host?: string + handles?: string[] +} + +export interface VideoChannelsSearchQueryAfterSanitize extends VideoChannelsSearchQuery { + start: number + count: number + sort: string +} diff --git a/packages/models/src/search/video-playlists-search-query.model.ts b/packages/models/src/search/video-playlists-search-query.model.ts new file mode 100644 index 000000000..65ac7b4d7 --- /dev/null +++ b/packages/models/src/search/video-playlists-search-query.model.ts @@ -0,0 +1,20 @@ +import { SearchTargetQuery } from './search-target-query.model.js' + +export interface VideoPlaylistsSearchQuery extends SearchTargetQuery { + search?: string + + start?: number + count?: number + sort?: string + + host?: string + + // UUIDs or short UUIDs + uuids?: string[] +} + +export interface VideoPlaylistsSearchQueryAfterSanitize extends VideoPlaylistsSearchQuery { + start: number + count: number + sort: string +} diff --git a/packages/models/src/search/videos-common-query.model.ts b/packages/models/src/search/videos-common-query.model.ts new file mode 100644 index 000000000..45181a739 --- /dev/null +++ b/packages/models/src/search/videos-common-query.model.ts @@ -0,0 +1,45 @@ +import { VideoIncludeType } from '../videos/video-include.enum.js' +import { VideoPrivacyType } from '../videos/video-privacy.enum.js' +import { BooleanBothQuery } from './boolean-both-query.model.js' + +// These query parameters can be used with any endpoint that list videos +export interface VideosCommonQuery { + start?: number + count?: number + sort?: string + + nsfw?: BooleanBothQuery + + isLive?: boolean + + isLocal?: boolean + include?: VideoIncludeType + + categoryOneOf?: number[] + + licenceOneOf?: number[] + + languageOneOf?: string[] + + privacyOneOf?: VideoPrivacyType[] + + tagsOneOf?: string[] + tagsAllOf?: string[] + + hasHLSFiles?: boolean + + hasWebtorrentFiles?: boolean // TODO: remove in v7 + hasWebVideoFiles?: boolean + + skipCount?: boolean + + search?: string + + excludeAlreadyWatched?: boolean +} + +export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery { + start: number + count: number + sort: string +} diff --git a/packages/models/src/search/videos-search-query.model.ts b/packages/models/src/search/videos-search-query.model.ts new file mode 100644 index 000000000..bbaa8d23f --- /dev/null +++ b/packages/models/src/search/videos-search-query.model.ts @@ -0,0 +1,26 @@ +import { SearchTargetQuery } from './search-target-query.model.js' +import { VideosCommonQuery } from './videos-common-query.model.js' + +export interface VideosSearchQuery extends SearchTargetQuery, VideosCommonQuery { + search?: string + + host?: string + + startDate?: string // ISO 8601 + endDate?: string // ISO 8601 + + originallyPublishedStartDate?: string // ISO 8601 + originallyPublishedEndDate?: string // ISO 8601 + + durationMin?: number // seconds + durationMax?: number // seconds + + // UUIDs or short UUIDs + uuids?: string[] +} + +export interface VideosSearchQueryAfterSanitize extends VideosSearchQuery { + start: number + count: number + sort: string +} diff --git a/packages/models/src/server/about.model.ts b/packages/models/src/server/about.model.ts new file mode 100644 index 000000000..6d4ba63c4 --- /dev/null +++ b/packages/models/src/server/about.model.ts @@ -0,0 +1,20 @@ +export interface About { + instance: { + name: string + shortDescription: string + description: string + terms: string + + codeOfConduct: string + hardwareInformation: string + + creationReason: string + moderationInformation: string + administrator: string + maintenanceLifetime: string + businessModel: string + + languages: string[] + categories: number[] + } +} diff --git a/packages/models/src/server/broadcast-message-level.type.ts b/packages/models/src/server/broadcast-message-level.type.ts new file mode 100644 index 000000000..bf43e18b5 --- /dev/null +++ b/packages/models/src/server/broadcast-message-level.type.ts @@ -0,0 +1 @@ +export type BroadcastMessageLevel = 'info' | 'warning' | 'error' diff --git a/packages/models/src/server/client-log-create.model.ts b/packages/models/src/server/client-log-create.model.ts new file mode 100644 index 000000000..543af0d3d --- /dev/null +++ b/packages/models/src/server/client-log-create.model.ts @@ -0,0 +1,11 @@ +import { ClientLogLevel } from './client-log-level.type.js' + +export interface ClientLogCreate { + message: string + url: string + level: ClientLogLevel + + stackTrace?: string + userAgent?: string + meta?: string +} diff --git a/packages/models/src/server/client-log-level.type.ts b/packages/models/src/server/client-log-level.type.ts new file mode 100644 index 000000000..18dea2751 --- /dev/null +++ b/packages/models/src/server/client-log-level.type.ts @@ -0,0 +1 @@ +export type ClientLogLevel = 'warn' | 'error' diff --git a/packages/models/src/server/contact-form.model.ts b/packages/models/src/server/contact-form.model.ts new file mode 100644 index 000000000..c23e6d1ba --- /dev/null +++ b/packages/models/src/server/contact-form.model.ts @@ -0,0 +1,6 @@ +export interface ContactForm { + fromEmail: string + fromName: string + subject: string + body: string +} diff --git a/packages/models/src/server/custom-config.model.ts b/packages/models/src/server/custom-config.model.ts new file mode 100644 index 000000000..df4176ba7 --- /dev/null +++ b/packages/models/src/server/custom-config.model.ts @@ -0,0 +1,259 @@ +import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' +import { BroadcastMessageLevel } from './broadcast-message-level.type.js' + +export type ConfigResolutions = { + '144p': boolean + '240p': boolean + '360p': boolean + '480p': boolean + '720p': boolean + '1080p': boolean + '1440p': boolean + '2160p': boolean +} + +export interface CustomConfig { + instance: { + name: string + shortDescription: string + description: string + terms: string + codeOfConduct: string + + creationReason: string + moderationInformation: string + administrator: string + maintenanceLifetime: string + businessModel: string + hardwareInformation: string + + languages: string[] + categories: number[] + + isNSFW: boolean + defaultNSFWPolicy: NSFWPolicyType + + defaultClientRoute: string + + customizations: { + javascript?: string + css?: string + } + } + + theme: { + default: string + } + + services: { + twitter: { + username: string + whitelisted: boolean + } + } + + client: { + videos: { + miniature: { + preferAuthorDisplayName: boolean + } + } + + menu: { + login: { + redirectOnSingleExternalAuth: boolean + } + } + } + + cache: { + previews: { + size: number + } + + captions: { + size: number + } + + torrents: { + size: number + } + + storyboards: { + size: number + } + } + + signup: { + enabled: boolean + limit: number + requiresApproval: boolean + requiresEmailVerification: boolean + minimumAge: number + } + + admin: { + email: string + } + + contactForm: { + enabled: boolean + } + + user: { + history: { + videos: { + enabled: boolean + } + } + videoQuota: number + videoQuotaDaily: number + } + + videoChannels: { + maxPerUser: number + } + + transcoding: { + enabled: boolean + + allowAdditionalExtensions: boolean + allowAudioFiles: boolean + + remoteRunners: { + enabled: boolean + } + + threads: number + concurrency: number + + profile: string + + resolutions: ConfigResolutions & { '0p': boolean } + + alwaysTranscodeOriginalResolution: boolean + + webVideos: { + enabled: boolean + } + + hls: { + enabled: boolean + } + } + + live: { + enabled: boolean + + allowReplay: boolean + + latencySetting: { + enabled: boolean + } + + maxDuration: number + maxInstanceLives: number + maxUserLives: number + + transcoding: { + enabled: boolean + remoteRunners: { + enabled: boolean + } + threads: number + profile: string + resolutions: ConfigResolutions + alwaysTranscodeOriginalResolution: boolean + } + } + + videoStudio: { + enabled: boolean + + remoteRunners: { + enabled: boolean + } + } + + videoFile: { + update: { + enabled: boolean + } + } + + import: { + videos: { + concurrency: number + + http: { + enabled: boolean + } + torrent: { + enabled: boolean + } + } + videoChannelSynchronization: { + enabled: boolean + maxPerUser: number + } + } + + trending: { + videos: { + algorithms: { + enabled: string[] + default: string + } + } + } + + autoBlacklist: { + videos: { + ofUsers: { + enabled: boolean + } + } + } + + followers: { + instance: { + enabled: boolean + manualApproval: boolean + } + } + + followings: { + instance: { + autoFollowBack: { + enabled: boolean + } + + autoFollowIndex: { + enabled: boolean + indexUrl: string + } + } + } + + broadcastMessage: { + enabled: boolean + message: string + level: BroadcastMessageLevel + dismissable: boolean + } + + search: { + remoteUri: { + users: boolean + anonymous: boolean + } + + searchIndex: { + enabled: boolean + url: string + disableLocalSearch: boolean + isDefaultSearch: boolean + } + } + +} diff --git a/packages/models/src/server/debug.model.ts b/packages/models/src/server/debug.model.ts new file mode 100644 index 000000000..41f2109af --- /dev/null +++ b/packages/models/src/server/debug.model.ts @@ -0,0 +1,12 @@ +export interface Debug { + ip: string + activityPubMessagesWaiting: number +} + +export interface SendDebugCommand { + command: 'remove-dandling-resumable-uploads' + | 'process-video-views-buffer' + | 'process-video-viewers' + | 'process-video-channel-sync-latest' + | 'process-update-videos-scheduler' +} diff --git a/packages/models/src/server/emailer.model.ts b/packages/models/src/server/emailer.model.ts new file mode 100644 index 000000000..39512d306 --- /dev/null +++ b/packages/models/src/server/emailer.model.ts @@ -0,0 +1,49 @@ +type From = string | { name?: string, address: string } + +interface Base extends Partial { + to: string[] | string +} + +interface MailTemplate extends Base { + template: string + locals?: { [key: string]: any } + text?: undefined +} + +interface MailText extends Base { + text: string + + locals?: Partial & { + title?: string + action?: { + url: string + text: string + } + } +} + +interface SendEmailDefaultLocalsOptions { + instanceName: string + text: string + subject: string +} + +interface SendEmailDefaultMessageOptions { + to: string[] | string + from: From + subject: string + replyTo: string +} + +export type SendEmailDefaultOptions = { + template: 'common' + + message: SendEmailDefaultMessageOptions + + locals: SendEmailDefaultLocalsOptions & { + WEBSERVER: any + EMAIL: any + } +} + +export type SendEmailOptions = MailTemplate | MailText diff --git a/packages/models/src/server/index.ts b/packages/models/src/server/index.ts new file mode 100644 index 000000000..ba6af8f6f --- /dev/null +++ b/packages/models/src/server/index.ts @@ -0,0 +1,16 @@ +export * from './about.model.js' +export * from './broadcast-message-level.type.js' +export * from './client-log-create.model.js' +export * from './client-log-level.type.js' +export * from './contact-form.model.js' +export * from './custom-config.model.js' +export * from './debug.model.js' +export * from './emailer.model.js' +export * from './job.model.js' +export * from './peertube-problem-document.model.js' +export * from './server-config.model.js' +export * from './server-debug.model.js' +export * from './server-error-code.enum.js' +export * from './server-follow-create.model.js' +export * from './server-log-level.type.js' +export * from './server-stats.model.js' diff --git a/packages/models/src/server/job.model.ts b/packages/models/src/server/job.model.ts new file mode 100644 index 000000000..f86a20e28 --- /dev/null +++ b/packages/models/src/server/job.model.ts @@ -0,0 +1,303 @@ +import { ContextType } from '../activitypub/context.js' +import { VideoStateType } from '../videos/index.js' +import { VideoStudioTaskCut } from '../videos/studio/index.js' +import { SendEmailOptions } from './emailer.model.js' + +export type JobState = 'active' | 'completed' | 'failed' | 'waiting' | 'delayed' | 'paused' | 'waiting-children' + +export type JobType = + | 'activitypub-cleaner' + | 'activitypub-follow' + | 'activitypub-http-broadcast-parallel' + | 'activitypub-http-broadcast' + | 'activitypub-http-fetcher' + | 'activitypub-http-unicast' + | 'activitypub-refresher' + | 'actor-keys' + | 'after-video-channel-import' + | 'email' + | 'federate-video' + | 'transcoding-job-builder' + | 'manage-video-torrent' + | 'move-to-object-storage' + | 'notify' + | 'video-channel-import' + | 'video-file-import' + | 'video-import' + | 'video-live-ending' + | 'video-redundancy' + | 'video-studio-edition' + | 'video-transcoding' + | 'videos-views-stats' + | 'generate-video-storyboard' + +export interface Job { + id: number | string + state: JobState | 'unknown' + type: JobType + data: any + priority: number + progress: number + error: any + createdAt: Date | string + finishedOn: Date | string + processedOn: Date | string + + parent?: { + id: string + } +} + +export type ActivitypubHttpBroadcastPayload = { + uris: string[] + contextType: ContextType + body: any + signatureActorId?: number +} + +export type ActivitypubFollowPayload = { + followerActorId: number + name: string + host: string + isAutoFollow?: boolean + assertIsChannel?: boolean +} + +export type FetchType = 'activity' | 'video-shares' | 'video-comments' | 'account-playlists' +export type ActivitypubHttpFetcherPayload = { + uri: string + type: FetchType + videoId?: number +} + +export type ActivitypubHttpUnicastPayload = { + uri: string + contextType: ContextType + signatureActorId?: number + body: object +} + +export type RefreshPayload = { + type: 'video' | 'video-playlist' | 'actor' + url: string +} + +export type EmailPayload = SendEmailOptions + +export type VideoFileImportPayload = { + videoUUID: string + filePath: string +} + +// --------------------------------------------------------------------------- + +export type VideoImportTorrentPayloadType = 'magnet-uri' | 'torrent-file' +export type VideoImportYoutubeDLPayloadType = 'youtube-dl' + +export interface VideoImportYoutubeDLPayload { + type: VideoImportYoutubeDLPayloadType + videoImportId: number + + fileExt?: string +} + +export interface VideoImportTorrentPayload { + type: VideoImportTorrentPayloadType + videoImportId: number +} + +export type VideoImportPayload = (VideoImportYoutubeDLPayload | VideoImportTorrentPayload) & { + preventException: boolean +} + +export interface VideoImportPreventExceptionResult { + resultType: 'success' | 'error' +} + +// --------------------------------------------------------------------------- + +export type VideoRedundancyPayload = { + videoId: number +} + +export type ManageVideoTorrentPayload = + { + action: 'create' + videoId: number + videoFileId: number + } | { + action: 'update-metadata' + + videoId?: number + streamingPlaylistId?: number + + videoFileId: number + } + +// Video transcoding payloads + +interface BaseTranscodingPayload { + videoUUID: string + isNewVideo?: boolean +} + +export interface HLSTranscodingPayload extends BaseTranscodingPayload { + type: 'new-resolution-to-hls' + resolution: number + fps: number + copyCodecs: boolean + + deleteWebVideoFiles: boolean +} + +export interface NewWebVideoResolutionTranscodingPayload extends BaseTranscodingPayload { + type: 'new-resolution-to-web-video' + resolution: number + fps: number +} + +export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload { + type: 'merge-audio-to-web-video' + + resolution: number + fps: number + + hasChildren: boolean +} + +export interface OptimizeTranscodingPayload extends BaseTranscodingPayload { + type: 'optimize-to-web-video' + + quickTranscode: boolean + + hasChildren: boolean +} + +export type VideoTranscodingPayload = + HLSTranscodingPayload + | NewWebVideoResolutionTranscodingPayload + | OptimizeTranscodingPayload + | MergeAudioTranscodingPayload + +export interface VideoLiveEndingPayload { + videoId: number + publishedAt: string + liveSessionId: number + streamingPlaylistId: number + + replayDirectory?: string +} + +export interface ActorKeysPayload { + actorId: number +} + +export interface DeleteResumableUploadMetaFilePayload { + filepath: string +} + +export interface MoveObjectStoragePayload { + videoUUID: string + isNewVideo: boolean + previousVideoState: VideoStateType +} + +export type VideoStudioTaskCutPayload = VideoStudioTaskCut + +export type VideoStudioTaskIntroPayload = { + name: 'add-intro' + + options: { + file: string + } +} + +export type VideoStudioTaskOutroPayload = { + name: 'add-outro' + + options: { + file: string + } +} + +export type VideoStudioTaskWatermarkPayload = { + name: 'add-watermark' + + options: { + file: string + + watermarkSizeRatio: number + horitonzalMarginRatio: number + verticalMarginRatio: number + } +} + +export type VideoStudioTaskPayload = + VideoStudioTaskCutPayload | + VideoStudioTaskIntroPayload | + VideoStudioTaskOutroPayload | + VideoStudioTaskWatermarkPayload + +export interface VideoStudioEditionPayload { + videoUUID: string + tasks: VideoStudioTaskPayload[] +} + +// --------------------------------------------------------------------------- + +export interface VideoChannelImportPayload { + externalChannelUrl: string + videoChannelId: number + + partOfChannelSyncId?: number +} + +export interface AfterVideoChannelImportPayload { + channelSyncId: number +} + +// --------------------------------------------------------------------------- + +export type NotifyPayload = + { + action: 'new-video' + videoUUID: string + } + +// --------------------------------------------------------------------------- + +export interface FederateVideoPayload { + videoUUID: string + isNewVideo: boolean +} + +// --------------------------------------------------------------------------- + +export interface TranscodingJobBuilderPayload { + videoUUID: string + + optimizeJob?: { + isNewVideo: boolean + } + + // Array of jobs to create + jobs?: { + type: 'video-transcoding' + payload: VideoTranscodingPayload + priority?: number + }[] + + // Array of sequential jobs to create + sequentialJobs?: { + type: 'video-transcoding' + payload: VideoTranscodingPayload + priority?: number + }[][] +} + +// --------------------------------------------------------------------------- + +export interface GenerateStoryboardPayload { + videoUUID: string + federate: boolean +} diff --git a/packages/models/src/server/peertube-problem-document.model.ts b/packages/models/src/server/peertube-problem-document.model.ts new file mode 100644 index 000000000..c717fc152 --- /dev/null +++ b/packages/models/src/server/peertube-problem-document.model.ts @@ -0,0 +1,32 @@ +import { HttpStatusCodeType } from '../http/http-status-codes.js' +import { OAuth2ErrorCodeType, ServerErrorCodeType } from './server-error-code.enum.js' + +export interface PeerTubeProblemDocumentData { + 'invalid-params'?: Record + + originUrl?: string + + keyId?: string + + targetUrl?: string + + actorUrl?: string + + // Feeds + format?: string + url?: string +} + +export interface PeerTubeProblemDocument extends PeerTubeProblemDocumentData { + type: string + title: string + + detail: string + // FIXME: Compat PeerTube <= 3.2 + error: string + + status: HttpStatusCodeType + + docs?: string + code?: OAuth2ErrorCodeType | ServerErrorCodeType +} diff --git a/packages/models/src/server/server-config.model.ts b/packages/models/src/server/server-config.model.ts new file mode 100644 index 000000000..a2a2bd5aa --- /dev/null +++ b/packages/models/src/server/server-config.model.ts @@ -0,0 +1,305 @@ +import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js' +import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' +import { VideoPrivacyType } from '../videos/video-privacy.enum.js' +import { BroadcastMessageLevel } from './broadcast-message-level.type.js' + +export interface ServerConfigPlugin { + name: string + npmName: string + version: string + description: string + clientScripts: { [name: string]: ClientScriptJSON } +} + +export interface ServerConfigTheme extends ServerConfigPlugin { + css: string[] +} + +export interface RegisteredExternalAuthConfig { + npmName: string + name: string + version: string + authName: string + authDisplayName: string +} + +export interface RegisteredIdAndPassAuthConfig { + npmName: string + name: string + version: string + authName: string + weight: number +} + +export interface ServerConfig { + serverVersion: string + serverCommit?: string + + client: { + videos: { + miniature: { + displayAuthorAvatar: boolean + preferAuthorDisplayName: boolean + } + resumableUpload: { + maxChunkSize: number + } + } + + menu: { + login: { + redirectOnSingleExternalAuth: boolean + } + } + } + + defaults: { + publish: { + downloadEnabled: boolean + commentsEnabled: boolean + privacy: VideoPrivacyType + licence: number + } + + p2p: { + webapp: { + enabled: boolean + } + + embed: { + enabled: boolean + } + } + } + + webadmin: { + configuration: { + edition: { + allowed: boolean + } + } + } + + instance: { + name: string + shortDescription: string + isNSFW: boolean + defaultNSFWPolicy: NSFWPolicyType + defaultClientRoute: string + customizations: { + javascript: string + css: string + } + } + + search: { + remoteUri: { + users: boolean + anonymous: boolean + } + + searchIndex: { + enabled: boolean + url: string + disableLocalSearch: boolean + isDefaultSearch: boolean + } + } + + plugin: { + registered: ServerConfigPlugin[] + + registeredExternalAuths: RegisteredExternalAuthConfig[] + + registeredIdAndPassAuths: RegisteredIdAndPassAuthConfig[] + } + + theme: { + registered: ServerConfigTheme[] + default: string + } + + email: { + enabled: boolean + } + + contactForm: { + enabled: boolean + } + + signup: { + allowed: boolean + allowedForCurrentIP: boolean + requiresEmailVerification: boolean + requiresApproval: boolean + minimumAge: number + } + + transcoding: { + hls: { + enabled: boolean + } + + web_videos: { + enabled: boolean + } + + enabledResolutions: number[] + + profile: string + availableProfiles: string[] + + remoteRunners: { + enabled: boolean + } + } + + live: { + enabled: boolean + + allowReplay: boolean + latencySetting: { + enabled: boolean + } + + maxDuration: number + maxInstanceLives: number + maxUserLives: number + + transcoding: { + enabled: boolean + + remoteRunners: { + enabled: boolean + } + + enabledResolutions: number[] + + profile: string + availableProfiles: string[] + } + + rtmp: { + port: number + } + } + + videoStudio: { + enabled: boolean + + remoteRunners: { + enabled: boolean + } + } + + videoFile: { + update: { + enabled: boolean + } + } + + import: { + videos: { + http: { + enabled: boolean + } + torrent: { + enabled: boolean + } + } + videoChannelSynchronization: { + enabled: boolean + } + } + + autoBlacklist: { + videos: { + ofUsers: { + enabled: boolean + } + } + } + + avatar: { + file: { + size: { + max: number + } + extensions: string[] + } + } + + banner: { + file: { + size: { + max: number + } + extensions: string[] + } + } + + video: { + image: { + size: { + max: number + } + extensions: string[] + } + file: { + extensions: string[] + } + } + + videoCaption: { + file: { + size: { + max: number + } + extensions: string[] + } + } + + user: { + videoQuota: number + videoQuotaDaily: number + } + + videoChannels: { + maxPerUser: number + } + + trending: { + videos: { + intervalDays: number + algorithms: { + enabled: string[] + default: string + } + } + } + + tracker: { + enabled: boolean + } + + followings: { + instance: { + autoFollowIndex: { + indexUrl: string + } + } + } + + broadcastMessage: { + enabled: boolean + message: string + level: BroadcastMessageLevel + dismissable: boolean + } + + homepage: { + enabled: boolean + } +} + +export type HTMLServerConfig = Omit diff --git a/packages/models/src/server/server-debug.model.ts b/packages/models/src/server/server-debug.model.ts new file mode 100644 index 000000000..4b731bb90 --- /dev/null +++ b/packages/models/src/server/server-debug.model.ts @@ -0,0 +1,4 @@ +export interface ServerDebug { + ip: string + activityPubMessagesWaiting: number +} diff --git a/packages/models/src/server/server-error-code.enum.ts b/packages/models/src/server/server-error-code.enum.ts new file mode 100644 index 000000000..dc200c1ea --- /dev/null +++ b/packages/models/src/server/server-error-code.enum.ts @@ -0,0 +1,92 @@ +export const ServerErrorCode = { + /** + * The simplest form of payload too large: when the file size is over the + * global file size limit + */ + MAX_FILE_SIZE_REACHED:'max_file_size_reached', + + /** + * The payload is too large for the user quota set + */ + QUOTA_REACHED:'quota_reached', + + /** + * Error yielded upon trying to access a video that is not federated, nor can + * be. This may be due to: remote videos on instances that are not followed by + * yours, and with your instance disallowing unknown instances being accessed. + */ + DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS:'does_not_respect_follow_constraints', + + LIVE_NOT_ENABLED:'live_not_enabled', + LIVE_NOT_ALLOWING_REPLAY:'live_not_allowing_replay', + LIVE_CONFLICTING_PERMANENT_AND_SAVE_REPLAY:'live_conflicting_permanent_and_save_replay', + /** + * Pretty self-explanatory: the set maximum number of simultaneous lives was + * reached, and this error is typically there to inform the user trying to + * broadcast one. + */ + MAX_INSTANCE_LIVES_LIMIT_REACHED:'max_instance_lives_limit_reached', + /** + * Pretty self-explanatory: the set maximum number of simultaneous lives FOR + * THIS USER was reached, and this error is typically there to inform the user + * trying to broadcast one. + */ + MAX_USER_LIVES_LIMIT_REACHED:'max_user_lives_limit_reached', + + /** + * A torrent should have at most one correct video file. Any more and we will + * not be able to choose automatically. + */ + INCORRECT_FILES_IN_TORRENT:'incorrect_files_in_torrent', + + COMMENT_NOT_ASSOCIATED_TO_VIDEO:'comment_not_associated_to_video', + + MISSING_TWO_FACTOR:'missing_two_factor', + INVALID_TWO_FACTOR:'invalid_two_factor', + + ACCOUNT_WAITING_FOR_APPROVAL:'account_waiting_for_approval', + ACCOUNT_APPROVAL_REJECTED:'account_approval_rejected', + + RUNNER_JOB_NOT_IN_PROCESSING_STATE:'runner_job_not_in_processing_state', + RUNNER_JOB_NOT_IN_PENDING_STATE:'runner_job_not_in_pending_state', + UNKNOWN_RUNNER_TOKEN:'unknown_runner_token', + + VIDEO_REQUIRES_PASSWORD:'video_requires_password', + INCORRECT_VIDEO_PASSWORD:'incorrect_video_password', + + VIDEO_ALREADY_BEING_TRANSCODED:'video_already_being_transcoded' +} as const + +/** + * oauthjs/oauth2-server error codes + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + **/ +export const OAuth2ErrorCode = { + /** + * The provided authorization grant (e.g., authorization code, resource owner + * credentials) or refresh token is invalid, expired, revoked, does not match + * the redirection URI used in the authorization request, or was issued to + * another client. + * + * @see https://github.com/oauthjs/node-oauth2-server/blob/master/lib/errors/invalid-grant-error.js + */ + INVALID_GRANT: 'invalid_grant', + + /** + * Client authentication failed (e.g., unknown client, no client authentication + * included, or unsupported authentication method). + * + * @see https://github.com/oauthjs/node-oauth2-server/blob/master/lib/errors/invalid-client-error.js + */ + INVALID_CLIENT: 'invalid_client', + + /** + * The access token provided is expired, revoked, malformed, or invalid for other reasons + * + * @see https://github.com/oauthjs/node-oauth2-server/blob/master/lib/errors/invalid-token-error.js + */ + INVALID_TOKEN: 'invalid_token' +} as const + +export type OAuth2ErrorCodeType = typeof OAuth2ErrorCode[keyof typeof OAuth2ErrorCode] +export type ServerErrorCodeType = typeof ServerErrorCode[keyof typeof ServerErrorCode] diff --git a/packages/models/src/server/server-follow-create.model.ts b/packages/models/src/server/server-follow-create.model.ts new file mode 100644 index 000000000..3f90c7d6f --- /dev/null +++ b/packages/models/src/server/server-follow-create.model.ts @@ -0,0 +1,4 @@ +export interface ServerFollowCreate { + hosts?: string[] + handles?: string[] +} diff --git a/packages/models/src/server/server-log-level.type.ts b/packages/models/src/server/server-log-level.type.ts new file mode 100644 index 000000000..f0f31a4ae --- /dev/null +++ b/packages/models/src/server/server-log-level.type.ts @@ -0,0 +1 @@ +export type ServerLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'audit' diff --git a/packages/models/src/server/server-stats.model.ts b/packages/models/src/server/server-stats.model.ts new file mode 100644 index 000000000..5870ee73d --- /dev/null +++ b/packages/models/src/server/server-stats.model.ts @@ -0,0 +1,47 @@ +import { ActivityType } from '../activitypub/index.js' +import { VideoRedundancyStrategyWithManual } from '../redundancy/index.js' + +type ActivityPubMessagesSuccess = Record<`totalActivityPub${ActivityType}MessagesSuccesses`, number> +type ActivityPubMessagesErrors = Record<`totalActivityPub${ActivityType}MessagesErrors`, number> + +export interface ServerStats extends ActivityPubMessagesSuccess, ActivityPubMessagesErrors { + totalUsers: number + totalDailyActiveUsers: number + totalWeeklyActiveUsers: number + totalMonthlyActiveUsers: number + + totalLocalVideos: number + totalLocalVideoViews: number + totalLocalVideoComments: number + totalLocalVideoFilesSize: number + + totalVideos: number + totalVideoComments: number + + totalLocalVideoChannels: number + totalLocalDailyActiveVideoChannels: number + totalLocalWeeklyActiveVideoChannels: number + totalLocalMonthlyActiveVideoChannels: number + + totalLocalPlaylists: number + + totalInstanceFollowers: number + totalInstanceFollowing: number + + videosRedundancy: VideosRedundancyStats[] + + totalActivityPubMessagesProcessed: number + totalActivityPubMessagesSuccesses: number + totalActivityPubMessagesErrors: number + + activityPubMessagesProcessedPerSecond: number + totalActivityPubMessagesWaiting: number +} + +export interface VideosRedundancyStats { + strategy: VideoRedundancyStrategyWithManual + totalSize: number + totalUsed: number + totalVideoFiles: number + totalVideos: number +} diff --git a/packages/models/src/tokens/index.ts b/packages/models/src/tokens/index.ts new file mode 100644 index 000000000..db2d63d21 --- /dev/null +++ b/packages/models/src/tokens/index.ts @@ -0,0 +1 @@ +export * from './oauth-client-local.model.js' diff --git a/packages/models/src/tokens/oauth-client-local.model.ts b/packages/models/src/tokens/oauth-client-local.model.ts new file mode 100644 index 000000000..0c6ce6c5d --- /dev/null +++ b/packages/models/src/tokens/oauth-client-local.model.ts @@ -0,0 +1,4 @@ +export interface OAuthClientLocal { + client_id: string + client_secret: string +} diff --git a/packages/models/src/users/index.ts b/packages/models/src/users/index.ts new file mode 100644 index 000000000..6f5218234 --- /dev/null +++ b/packages/models/src/users/index.ts @@ -0,0 +1,16 @@ +export * from './registration/index.js' +export * from './two-factor-enable-result.model.js' +export * from './user-create-result.model.js' +export * from './user-create.model.js' +export * from './user-flag.model.js' +export * from './user-login.model.js' +export * from './user-notification-setting.model.js' +export * from './user-notification.model.js' +export * from './user-refresh-token.model.js' +export * from './user-right.enum.js' +export * from './user-role.js' +export * from './user-scoped-token.js' +export * from './user-update-me.model.js' +export * from './user-update.model.js' +export * from './user-video-quota.model.js' +export * from './user.model.js' diff --git a/packages/models/src/users/registration/index.ts b/packages/models/src/users/registration/index.ts new file mode 100644 index 000000000..dcf16ef9d --- /dev/null +++ b/packages/models/src/users/registration/index.ts @@ -0,0 +1,5 @@ +export * from './user-register.model.js' +export * from './user-registration-request.model.js' +export * from './user-registration-state.model.js' +export * from './user-registration-update-state.model.js' +export * from './user-registration.model.js' diff --git a/packages/models/src/users/registration/user-register.model.ts b/packages/models/src/users/registration/user-register.model.ts new file mode 100644 index 000000000..cf9a43a67 --- /dev/null +++ b/packages/models/src/users/registration/user-register.model.ts @@ -0,0 +1,12 @@ +export interface UserRegister { + username: string + password: string + email: string + + displayName?: string + + channel?: { + name: string + displayName: string + } +} diff --git a/packages/models/src/users/registration/user-registration-request.model.ts b/packages/models/src/users/registration/user-registration-request.model.ts new file mode 100644 index 000000000..ed369f96a --- /dev/null +++ b/packages/models/src/users/registration/user-registration-request.model.ts @@ -0,0 +1,5 @@ +import { UserRegister } from './user-register.model.js' + +export interface UserRegistrationRequest extends UserRegister { + registrationReason: string +} diff --git a/packages/models/src/users/registration/user-registration-state.model.ts b/packages/models/src/users/registration/user-registration-state.model.ts new file mode 100644 index 000000000..7c51f3f9d --- /dev/null +++ b/packages/models/src/users/registration/user-registration-state.model.ts @@ -0,0 +1,7 @@ +export const UserRegistrationState = { + PENDING: 1, + REJECTED: 2, + ACCEPTED: 3 +} + +export type UserRegistrationStateType = typeof UserRegistrationState[keyof typeof UserRegistrationState] diff --git a/packages/models/src/users/registration/user-registration-update-state.model.ts b/packages/models/src/users/registration/user-registration-update-state.model.ts new file mode 100644 index 000000000..a1740dcca --- /dev/null +++ b/packages/models/src/users/registration/user-registration-update-state.model.ts @@ -0,0 +1,4 @@ +export interface UserRegistrationUpdateState { + moderationResponse: string + preventEmailDelivery?: boolean +} diff --git a/packages/models/src/users/registration/user-registration.model.ts b/packages/models/src/users/registration/user-registration.model.ts new file mode 100644 index 000000000..0d01add36 --- /dev/null +++ b/packages/models/src/users/registration/user-registration.model.ts @@ -0,0 +1,29 @@ +import { UserRegistrationStateType } from './user-registration-state.model.js' + +export interface UserRegistration { + id: number + + state: { + id: UserRegistrationStateType + label: string + } + + registrationReason: string + moderationResponse: string + + username: string + email: string + emailVerified: boolean + + accountDisplayName: string + + channelHandle: string + channelDisplayName: string + + createdAt: Date + updatedAt: Date + + user?: { + id: number + } +} diff --git a/packages/models/src/users/two-factor-enable-result.model.ts b/packages/models/src/users/two-factor-enable-result.model.ts new file mode 100644 index 000000000..1fc801f0a --- /dev/null +++ b/packages/models/src/users/two-factor-enable-result.model.ts @@ -0,0 +1,7 @@ +export interface TwoFactorEnableResult { + otpRequest: { + requestToken: string + secret: string + uri: string + } +} diff --git a/packages/models/src/users/user-create-result.model.ts b/packages/models/src/users/user-create-result.model.ts new file mode 100644 index 000000000..835b241ed --- /dev/null +++ b/packages/models/src/users/user-create-result.model.ts @@ -0,0 +1,7 @@ +export interface UserCreateResult { + id: number + + account: { + id: number + } +} diff --git a/packages/models/src/users/user-create.model.ts b/packages/models/src/users/user-create.model.ts new file mode 100644 index 000000000..b62cf692f --- /dev/null +++ b/packages/models/src/users/user-create.model.ts @@ -0,0 +1,13 @@ +import { UserAdminFlagType } from './user-flag.model.js' +import { UserRoleType } from './user-role.js' + +export interface UserCreate { + username: string + password: string + email: string + videoQuota: number + videoQuotaDaily: number + role: UserRoleType + adminFlags?: UserAdminFlagType + channelName?: string +} diff --git a/packages/models/src/users/user-flag.model.ts b/packages/models/src/users/user-flag.model.ts new file mode 100644 index 000000000..0ecbacecc --- /dev/null +++ b/packages/models/src/users/user-flag.model.ts @@ -0,0 +1,6 @@ +export const UserAdminFlag = { + NONE: 0, + BYPASS_VIDEO_AUTO_BLACKLIST: 1 << 0 +} as const + +export type UserAdminFlagType = typeof UserAdminFlag[keyof typeof UserAdminFlag] diff --git a/packages/models/src/users/user-login.model.ts b/packages/models/src/users/user-login.model.ts new file mode 100644 index 000000000..1e85ab30b --- /dev/null +++ b/packages/models/src/users/user-login.model.ts @@ -0,0 +1,5 @@ +export interface UserLogin { + access_token: string + refresh_token: string + token_type: string +} diff --git a/packages/models/src/users/user-notification-setting.model.ts b/packages/models/src/users/user-notification-setting.model.ts new file mode 100644 index 000000000..fbd94994e --- /dev/null +++ b/packages/models/src/users/user-notification-setting.model.ts @@ -0,0 +1,34 @@ +export const UserNotificationSettingValue = { + NONE: 0, + WEB: 1 << 0, + EMAIL: 1 << 1 +} as const + +export type UserNotificationSettingValueType = typeof UserNotificationSettingValue[keyof typeof UserNotificationSettingValue] + +export interface UserNotificationSetting { + abuseAsModerator: UserNotificationSettingValueType + videoAutoBlacklistAsModerator: UserNotificationSettingValueType + newUserRegistration: UserNotificationSettingValueType + + newVideoFromSubscription: UserNotificationSettingValueType + + blacklistOnMyVideo: UserNotificationSettingValueType + myVideoPublished: UserNotificationSettingValueType + myVideoImportFinished: UserNotificationSettingValueType + + commentMention: UserNotificationSettingValueType + newCommentOnMyVideo: UserNotificationSettingValueType + + newFollow: UserNotificationSettingValueType + newInstanceFollower: UserNotificationSettingValueType + autoInstanceFollowing: UserNotificationSettingValueType + + abuseStateChange: UserNotificationSettingValueType + abuseNewMessage: UserNotificationSettingValueType + + newPeerTubeVersion: UserNotificationSettingValueType + newPluginVersion: UserNotificationSettingValueType + + myVideoStudioEditionFinished: UserNotificationSettingValueType +} diff --git a/packages/models/src/users/user-notification.model.ts b/packages/models/src/users/user-notification.model.ts new file mode 100644 index 000000000..991fe6728 --- /dev/null +++ b/packages/models/src/users/user-notification.model.ts @@ -0,0 +1,140 @@ +import { FollowState } from '../actors/index.js' +import { AbuseStateType } from '../moderation/index.js' +import { PluginType_Type } from '../plugins/index.js' + +export const UserNotificationType = { + NEW_VIDEO_FROM_SUBSCRIPTION: 1, + NEW_COMMENT_ON_MY_VIDEO: 2, + NEW_ABUSE_FOR_MODERATORS: 3, + + BLACKLIST_ON_MY_VIDEO: 4, + UNBLACKLIST_ON_MY_VIDEO: 5, + + MY_VIDEO_PUBLISHED: 6, + + MY_VIDEO_IMPORT_SUCCESS: 7, + MY_VIDEO_IMPORT_ERROR: 8, + + NEW_USER_REGISTRATION: 9, + NEW_FOLLOW: 10, + COMMENT_MENTION: 11, + + VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: 12, + + NEW_INSTANCE_FOLLOWER: 13, + + AUTO_INSTANCE_FOLLOWING: 14, + + ABUSE_STATE_CHANGE: 15, + + ABUSE_NEW_MESSAGE: 16, + + NEW_PLUGIN_VERSION: 17, + NEW_PEERTUBE_VERSION: 18, + + MY_VIDEO_STUDIO_EDITION_FINISHED: 19, + + NEW_USER_REGISTRATION_REQUEST: 20 +} as const + +export type UserNotificationType_Type = typeof UserNotificationType[keyof typeof UserNotificationType] + +export interface VideoInfo { + id: number + uuid: string + shortUUID: string + name: string +} + +export interface AvatarInfo { + width: number + path: string +} + +export interface ActorInfo { + id: number + displayName: string + name: string + host: string + + avatars: AvatarInfo[] + avatar: AvatarInfo +} + +export interface UserNotification { + id: number + type: UserNotificationType_Type + read: boolean + + video?: VideoInfo & { + channel: ActorInfo + } + + videoImport?: { + id: number + video?: VideoInfo + torrentName?: string + magnetUri?: string + targetUrl?: string + } + + comment?: { + id: number + threadId: number + account: ActorInfo + video: VideoInfo + } + + abuse?: { + id: number + state: AbuseStateType + + video?: VideoInfo + + comment?: { + threadId: number + + video: VideoInfo + } + + account?: ActorInfo + } + + videoBlacklist?: { + id: number + video: VideoInfo + } + + account?: ActorInfo + + actorFollow?: { + id: number + follower: ActorInfo + state: FollowState + + following: { + type: 'account' | 'channel' | 'instance' + name: string + displayName: string + host: string + } + } + + plugin?: { + name: string + type: PluginType_Type + latestVersion: string + } + + peertube?: { + latestVersion: string + } + + registration?: { + id: number + username: string + } + + createdAt: string + updatedAt: string +} diff --git a/packages/models/src/users/user-refresh-token.model.ts b/packages/models/src/users/user-refresh-token.model.ts new file mode 100644 index 000000000..f528dd961 --- /dev/null +++ b/packages/models/src/users/user-refresh-token.model.ts @@ -0,0 +1,4 @@ +export interface UserRefreshToken { + access_token: string + refresh_token: string +} diff --git a/packages/models/src/users/user-right.enum.ts b/packages/models/src/users/user-right.enum.ts new file mode 100644 index 000000000..534b9feb0 --- /dev/null +++ b/packages/models/src/users/user-right.enum.ts @@ -0,0 +1,53 @@ +export const UserRight = { + ALL: 0, + + MANAGE_USERS: 1, + + MANAGE_SERVER_FOLLOW: 2, + + MANAGE_LOGS: 3, + + MANAGE_DEBUG: 4, + + MANAGE_SERVER_REDUNDANCY: 5, + + MANAGE_ABUSES: 6, + + MANAGE_JOBS: 7, + + MANAGE_CONFIGURATION: 8, + MANAGE_INSTANCE_CUSTOM_PAGE: 9, + + MANAGE_ACCOUNTS_BLOCKLIST: 10, + MANAGE_SERVERS_BLOCKLIST: 11, + + MANAGE_VIDEO_BLACKLIST: 12, + MANAGE_ANY_VIDEO_CHANNEL: 13, + + REMOVE_ANY_VIDEO: 14, + REMOVE_ANY_VIDEO_PLAYLIST: 15, + REMOVE_ANY_VIDEO_COMMENT: 16, + + UPDATE_ANY_VIDEO: 17, + UPDATE_ANY_VIDEO_PLAYLIST: 18, + + GET_ANY_LIVE: 19, + SEE_ALL_VIDEOS: 20, + SEE_ALL_COMMENTS: 21, + CHANGE_VIDEO_OWNERSHIP: 22, + + MANAGE_PLUGINS: 23, + + MANAGE_VIDEOS_REDUNDANCIES: 24, + + MANAGE_VIDEO_FILES: 25, + RUN_VIDEO_TRANSCODING: 26, + + MANAGE_VIDEO_IMPORTS: 27, + + MANAGE_REGISTRATIONS: 28, + + MANAGE_RUNNERS: 29 +} as const + +export type UserRightType = typeof UserRight[keyof typeof UserRight] diff --git a/packages/models/src/users/user-role.ts b/packages/models/src/users/user-role.ts new file mode 100644 index 000000000..b496f8153 --- /dev/null +++ b/packages/models/src/users/user-role.ts @@ -0,0 +1,8 @@ +// Always keep this order to prevent security issue since we store these values in the database +export const UserRole = { + ADMINISTRATOR: 0, + MODERATOR: 1, + USER: 2 +} as const + +export type UserRoleType = typeof UserRole[keyof typeof UserRole] diff --git a/packages/models/src/users/user-scoped-token.ts b/packages/models/src/users/user-scoped-token.ts new file mode 100644 index 000000000..f9d9b0a8b --- /dev/null +++ b/packages/models/src/users/user-scoped-token.ts @@ -0,0 +1,5 @@ +export type ScopedTokenType = 'feedToken' + +export type ScopedToken = { + feedToken: string +} diff --git a/packages/models/src/users/user-update-me.model.ts b/packages/models/src/users/user-update-me.model.ts new file mode 100644 index 000000000..ba9672136 --- /dev/null +++ b/packages/models/src/users/user-update-me.model.ts @@ -0,0 +1,26 @@ +import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' + +export interface UserUpdateMe { + displayName?: string + description?: string + nsfwPolicy?: NSFWPolicyType + + p2pEnabled?: boolean + + autoPlayVideo?: boolean + autoPlayNextVideo?: boolean + autoPlayNextVideoPlaylist?: boolean + videosHistoryEnabled?: boolean + videoLanguages?: string[] + + email?: string + emailPublic?: boolean + currentPassword?: string + password?: string + + theme?: string + + noInstanceConfigWarningModal?: boolean + noWelcomeModal?: boolean + noAccountSetupWarningModal?: boolean +} diff --git a/packages/models/src/users/user-update.model.ts b/packages/models/src/users/user-update.model.ts new file mode 100644 index 000000000..283255629 --- /dev/null +++ b/packages/models/src/users/user-update.model.ts @@ -0,0 +1,13 @@ +import { UserAdminFlagType } from './user-flag.model.js' +import { UserRoleType } from './user-role.js' + +export interface UserUpdate { + password?: string + email?: string + emailVerified?: boolean + videoQuota?: number + videoQuotaDaily?: number + role?: UserRoleType + adminFlags?: UserAdminFlagType + pluginAuth?: string +} diff --git a/packages/models/src/users/user-video-quota.model.ts b/packages/models/src/users/user-video-quota.model.ts new file mode 100644 index 000000000..a24871d71 --- /dev/null +++ b/packages/models/src/users/user-video-quota.model.ts @@ -0,0 +1,4 @@ +export interface UserVideoQuota { + videoQuotaUsed: number + videoQuotaUsedDaily: number +} diff --git a/packages/models/src/users/user.model.ts b/packages/models/src/users/user.model.ts new file mode 100644 index 000000000..57b4c1aab --- /dev/null +++ b/packages/models/src/users/user.model.ts @@ -0,0 +1,78 @@ +import { Account } from '../actors/index.js' +import { VideoChannel } from '../videos/channel/video-channel.model.js' +import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' +import { VideoPlaylistType_Type } from '../videos/playlist/video-playlist-type.model.js' +import { UserAdminFlagType } from './user-flag.model.js' +import { UserNotificationSetting } from './user-notification-setting.model.js' +import { UserRoleType } from './user-role.js' + +export interface User { + id: number + username: string + email: string + pendingEmail: string | null + + emailVerified: boolean + emailPublic: boolean + nsfwPolicy: NSFWPolicyType + + adminFlags?: UserAdminFlagType + + autoPlayVideo: boolean + autoPlayNextVideo: boolean + autoPlayNextVideoPlaylist: boolean + + p2pEnabled: boolean + + videosHistoryEnabled: boolean + videoLanguages: string[] + + role: { + id: UserRoleType + label: string + } + + videoQuota: number + videoQuotaDaily: number + videoQuotaUsed?: number + videoQuotaUsedDaily?: number + + videosCount?: number + + abusesCount?: number + abusesAcceptedCount?: number + abusesCreatedCount?: number + + videoCommentsCount?: number + + theme: string + + account: Account + notificationSettings?: UserNotificationSetting + videoChannels?: VideoChannel[] + + blocked: boolean + blockedReason?: string + + noInstanceConfigWarningModal: boolean + noWelcomeModal: boolean + noAccountSetupWarningModal: boolean + + createdAt: Date + + pluginAuth: string | null + + lastLoginDate: Date | null + + twoFactorEnabled: boolean +} + +export interface MyUserSpecialPlaylist { + id: number + name: string + type: VideoPlaylistType_Type +} + +export interface MyUser extends User { + specialPlaylists: MyUserSpecialPlaylist[] +} diff --git a/packages/models/src/videos/blacklist/index.ts b/packages/models/src/videos/blacklist/index.ts new file mode 100644 index 000000000..5eb36ad48 --- /dev/null +++ b/packages/models/src/videos/blacklist/index.ts @@ -0,0 +1,3 @@ +export * from './video-blacklist.model.js' +export * from './video-blacklist-create.model.js' +export * from './video-blacklist-update.model.js' diff --git a/packages/models/src/videos/blacklist/video-blacklist-create.model.ts b/packages/models/src/videos/blacklist/video-blacklist-create.model.ts new file mode 100644 index 000000000..6e7d36421 --- /dev/null +++ b/packages/models/src/videos/blacklist/video-blacklist-create.model.ts @@ -0,0 +1,4 @@ +export interface VideoBlacklistCreate { + reason?: string + unfederate?: boolean +} diff --git a/packages/models/src/videos/blacklist/video-blacklist-update.model.ts b/packages/models/src/videos/blacklist/video-blacklist-update.model.ts new file mode 100644 index 000000000..0a86cf7b0 --- /dev/null +++ b/packages/models/src/videos/blacklist/video-blacklist-update.model.ts @@ -0,0 +1,3 @@ +export interface VideoBlacklistUpdate { + reason?: string +} diff --git a/packages/models/src/videos/blacklist/video-blacklist.model.ts b/packages/models/src/videos/blacklist/video-blacklist.model.ts new file mode 100644 index 000000000..1ca5bbbb7 --- /dev/null +++ b/packages/models/src/videos/blacklist/video-blacklist.model.ts @@ -0,0 +1,20 @@ +import { Video } from '../video.model.js' + +export const VideoBlacklistType = { + MANUAL: 1, + AUTO_BEFORE_PUBLISHED: 2 +} as const + +export type VideoBlacklistType_Type = typeof VideoBlacklistType[keyof typeof VideoBlacklistType] + +export interface VideoBlacklist { + id: number + unfederated: boolean + reason?: string + type: VideoBlacklistType_Type + + video: Video + + createdAt: Date + updatedAt: Date +} diff --git a/packages/models/src/videos/caption/index.ts b/packages/models/src/videos/caption/index.ts new file mode 100644 index 000000000..a175768ce --- /dev/null +++ b/packages/models/src/videos/caption/index.ts @@ -0,0 +1,2 @@ +export * from './video-caption.model.js' +export * from './video-caption-update.model.js' diff --git a/packages/models/src/videos/caption/video-caption-update.model.ts b/packages/models/src/videos/caption/video-caption-update.model.ts new file mode 100644 index 000000000..ff5728715 --- /dev/null +++ b/packages/models/src/videos/caption/video-caption-update.model.ts @@ -0,0 +1,4 @@ +export interface VideoCaptionUpdate { + language: string + captionfile: Blob +} diff --git a/packages/models/src/videos/caption/video-caption.model.ts b/packages/models/src/videos/caption/video-caption.model.ts new file mode 100644 index 000000000..d6d625ff7 --- /dev/null +++ b/packages/models/src/videos/caption/video-caption.model.ts @@ -0,0 +1,7 @@ +import { VideoConstant } from '../video-constant.model.js' + +export interface VideoCaption { + language: VideoConstant + captionPath: string + updatedAt: string +} diff --git a/packages/models/src/videos/change-ownership/index.ts b/packages/models/src/videos/change-ownership/index.ts new file mode 100644 index 000000000..6cf568f4e --- /dev/null +++ b/packages/models/src/videos/change-ownership/index.ts @@ -0,0 +1,3 @@ +export * from './video-change-ownership-accept.model.js' +export * from './video-change-ownership-create.model.js' +export * from './video-change-ownership.model.js' diff --git a/packages/models/src/videos/change-ownership/video-change-ownership-accept.model.ts b/packages/models/src/videos/change-ownership/video-change-ownership-accept.model.ts new file mode 100644 index 000000000..f27247633 --- /dev/null +++ b/packages/models/src/videos/change-ownership/video-change-ownership-accept.model.ts @@ -0,0 +1,3 @@ +export interface VideoChangeOwnershipAccept { + channelId: number +} diff --git a/packages/models/src/videos/change-ownership/video-change-ownership-create.model.ts b/packages/models/src/videos/change-ownership/video-change-ownership-create.model.ts new file mode 100644 index 000000000..40fcca285 --- /dev/null +++ b/packages/models/src/videos/change-ownership/video-change-ownership-create.model.ts @@ -0,0 +1,3 @@ +export interface VideoChangeOwnershipCreate { + username: string +} diff --git a/packages/models/src/videos/change-ownership/video-change-ownership.model.ts b/packages/models/src/videos/change-ownership/video-change-ownership.model.ts new file mode 100644 index 000000000..353db37f0 --- /dev/null +++ b/packages/models/src/videos/change-ownership/video-change-ownership.model.ts @@ -0,0 +1,19 @@ +import { Account } from '../../actors/index.js' +import { Video } from '../video.model.js' + +export interface VideoChangeOwnership { + id: number + status: VideoChangeOwnershipStatusType + initiatorAccount: Account + nextOwnerAccount: Account + video: Video + createdAt: Date +} + +export const VideoChangeOwnershipStatus = { + WAITING: 'WAITING', + ACCEPTED: 'ACCEPTED', + REFUSED: 'REFUSED' +} as const + +export type VideoChangeOwnershipStatusType = typeof VideoChangeOwnershipStatus[keyof typeof VideoChangeOwnershipStatus] diff --git a/packages/models/src/videos/channel-sync/index.ts b/packages/models/src/videos/channel-sync/index.ts new file mode 100644 index 000000000..206cbe1b6 --- /dev/null +++ b/packages/models/src/videos/channel-sync/index.ts @@ -0,0 +1,3 @@ +export * from './video-channel-sync-state.enum.js' +export * from './video-channel-sync.model.js' +export * from './video-channel-sync-create.model.js' diff --git a/packages/models/src/videos/channel-sync/video-channel-sync-create.model.ts b/packages/models/src/videos/channel-sync/video-channel-sync-create.model.ts new file mode 100644 index 000000000..753a8ee4c --- /dev/null +++ b/packages/models/src/videos/channel-sync/video-channel-sync-create.model.ts @@ -0,0 +1,4 @@ +export interface VideoChannelSyncCreate { + externalChannelUrl: string + videoChannelId: number +} diff --git a/packages/models/src/videos/channel-sync/video-channel-sync-state.enum.ts b/packages/models/src/videos/channel-sync/video-channel-sync-state.enum.ts new file mode 100644 index 000000000..047444bbc --- /dev/null +++ b/packages/models/src/videos/channel-sync/video-channel-sync-state.enum.ts @@ -0,0 +1,8 @@ +export const VideoChannelSyncState = { + WAITING_FIRST_RUN: 1, + PROCESSING: 2, + SYNCED: 3, + FAILED: 4 +} as const + +export type VideoChannelSyncStateType = typeof VideoChannelSyncState[keyof typeof VideoChannelSyncState] diff --git a/packages/models/src/videos/channel-sync/video-channel-sync.model.ts b/packages/models/src/videos/channel-sync/video-channel-sync.model.ts new file mode 100644 index 000000000..38ac99668 --- /dev/null +++ b/packages/models/src/videos/channel-sync/video-channel-sync.model.ts @@ -0,0 +1,14 @@ +import { VideoChannelSummary } from '../channel/video-channel.model.js' +import { VideoConstant } from '../video-constant.model.js' +import { VideoChannelSyncStateType } from './video-channel-sync-state.enum.js' + +export interface VideoChannelSync { + id: number + + externalChannelUrl: string + + createdAt: string + channel: VideoChannelSummary + state: VideoConstant + lastSyncAt: string +} diff --git a/packages/models/src/videos/channel/index.ts b/packages/models/src/videos/channel/index.ts new file mode 100644 index 000000000..3c96e80f0 --- /dev/null +++ b/packages/models/src/videos/channel/index.ts @@ -0,0 +1,4 @@ +export * from './video-channel-create-result.model.js' +export * from './video-channel-create.model.js' +export * from './video-channel-update.model.js' +export * from './video-channel.model.js' diff --git a/packages/models/src/videos/channel/video-channel-create-result.model.ts b/packages/models/src/videos/channel/video-channel-create-result.model.ts new file mode 100644 index 000000000..e3d7aeb4c --- /dev/null +++ b/packages/models/src/videos/channel/video-channel-create-result.model.ts @@ -0,0 +1,3 @@ +export interface VideoChannelCreateResult { + id: number +} diff --git a/packages/models/src/videos/channel/video-channel-create.model.ts b/packages/models/src/videos/channel/video-channel-create.model.ts new file mode 100644 index 000000000..da8ce620c --- /dev/null +++ b/packages/models/src/videos/channel/video-channel-create.model.ts @@ -0,0 +1,6 @@ +export interface VideoChannelCreate { + name: string + displayName: string + description?: string + support?: string +} diff --git a/packages/models/src/videos/channel/video-channel-update.model.ts b/packages/models/src/videos/channel/video-channel-update.model.ts new file mode 100644 index 000000000..8dde9188b --- /dev/null +++ b/packages/models/src/videos/channel/video-channel-update.model.ts @@ -0,0 +1,7 @@ +export interface VideoChannelUpdate { + displayName?: string + description?: string + support?: string + + bulkVideosSupportUpdate?: boolean +} diff --git a/packages/models/src/videos/channel/video-channel.model.ts b/packages/models/src/videos/channel/video-channel.model.ts new file mode 100644 index 000000000..bb10f6da5 --- /dev/null +++ b/packages/models/src/videos/channel/video-channel.model.ts @@ -0,0 +1,34 @@ +import { Account, ActorImage } from '../../actors/index.js' +import { Actor } from '../../actors/actor.model.js' + +export type ViewsPerDate = { + date: Date + views: number +} + +export interface VideoChannel extends Actor { + displayName: string + description: string + support: string + isLocal: boolean + + updatedAt: Date | string + + ownerAccount?: Account + + videosCount?: number + viewsPerDay?: ViewsPerDate[] // chronologically ordered + totalViews?: number + + banners: ActorImage[] +} + +export interface VideoChannelSummary { + id: number + name: string + displayName: string + url: string + host: string + + avatars: ActorImage[] +} diff --git a/packages/models/src/videos/comment/index.ts b/packages/models/src/videos/comment/index.ts new file mode 100644 index 000000000..bd26c652d --- /dev/null +++ b/packages/models/src/videos/comment/index.ts @@ -0,0 +1,2 @@ +export * from './video-comment-create.model.js' +export * from './video-comment.model.js' diff --git a/packages/models/src/videos/comment/video-comment-create.model.ts b/packages/models/src/videos/comment/video-comment-create.model.ts new file mode 100644 index 000000000..1f0135405 --- /dev/null +++ b/packages/models/src/videos/comment/video-comment-create.model.ts @@ -0,0 +1,3 @@ +export interface VideoCommentCreate { + text: string +} diff --git a/packages/models/src/videos/comment/video-comment.model.ts b/packages/models/src/videos/comment/video-comment.model.ts new file mode 100644 index 000000000..e2266545a --- /dev/null +++ b/packages/models/src/videos/comment/video-comment.model.ts @@ -0,0 +1,45 @@ +import { ResultList } from '../../common/index.js' +import { Account } from '../../actors/index.js' + +export interface VideoComment { + id: number + url: string + text: string + threadId: number + inReplyToCommentId: number + videoId: number + createdAt: Date | string + updatedAt: Date | string + deletedAt: Date | string + isDeleted: boolean + totalRepliesFromVideoAuthor: number + totalReplies: number + account: Account +} + +export interface VideoCommentAdmin { + id: number + url: string + text: string + + threadId: number + inReplyToCommentId: number + + createdAt: Date | string + updatedAt: Date | string + + account: Account + + video: { + id: number + uuid: string + name: string + } +} + +export type VideoCommentThreads = ResultList & { totalNotDeletedComments: number } + +export interface VideoCommentThreadTree { + comment: VideoComment + children: VideoCommentThreadTree[] +} diff --git a/packages/models/src/videos/file/index.ts b/packages/models/src/videos/file/index.ts new file mode 100644 index 000000000..ee06f4e20 --- /dev/null +++ b/packages/models/src/videos/file/index.ts @@ -0,0 +1,3 @@ +export * from './video-file-metadata.model.js' +export * from './video-file.model.js' +export * from './video-resolution.enum.js' diff --git a/packages/models/src/videos/file/video-file-metadata.model.ts b/packages/models/src/videos/file/video-file-metadata.model.ts new file mode 100644 index 000000000..8f527c0a7 --- /dev/null +++ b/packages/models/src/videos/file/video-file-metadata.model.ts @@ -0,0 +1,13 @@ +export class VideoFileMetadata { + streams: { [x: string]: any, [x: number]: any }[] + format: { [x: string]: any, [x: number]: any } + chapters: any[] + + constructor (hash: { chapters: any[], format: any, streams: any[] }) { + this.chapters = hash.chapters + this.format = hash.format + this.streams = hash.streams + + delete this.format.filename + } +} diff --git a/packages/models/src/videos/file/video-file.model.ts b/packages/models/src/videos/file/video-file.model.ts new file mode 100644 index 000000000..2ed1ac4be --- /dev/null +++ b/packages/models/src/videos/file/video-file.model.ts @@ -0,0 +1,22 @@ +import { VideoConstant } from '../video-constant.model.js' +import { VideoFileMetadata } from './video-file-metadata.model.js' + +export interface VideoFile { + id: number + + resolution: VideoConstant + size: number // Bytes + + torrentUrl: string + torrentDownloadUrl: string + + fileUrl: string + fileDownloadUrl: string + + fps: number + + metadata?: VideoFileMetadata + metadataUrl?: string + + magnetUri: string | null +} diff --git a/packages/models/src/videos/file/video-resolution.enum.ts b/packages/models/src/videos/file/video-resolution.enum.ts new file mode 100644 index 000000000..434e8c36d --- /dev/null +++ b/packages/models/src/videos/file/video-resolution.enum.ts @@ -0,0 +1,13 @@ +export const VideoResolution = { + H_NOVIDEO: 0, + H_144P: 144, + H_240P: 240, + H_360P: 360, + H_480P: 480, + H_720P: 720, + H_1080P: 1080, + H_1440P: 1440, + H_4K: 2160 +} as const + +export type VideoResolutionType = typeof VideoResolution[keyof typeof VideoResolution] diff --git a/packages/models/src/videos/import/index.ts b/packages/models/src/videos/import/index.ts new file mode 100644 index 000000000..6701674c5 --- /dev/null +++ b/packages/models/src/videos/import/index.ts @@ -0,0 +1,4 @@ +export * from './video-import-create.model.js' +export * from './video-import-state.enum.js' +export * from './video-import.model.js' +export * from './videos-import-in-channel-create.model.js' diff --git a/packages/models/src/videos/import/video-import-create.model.ts b/packages/models/src/videos/import/video-import-create.model.ts new file mode 100644 index 000000000..3ec0d22f3 --- /dev/null +++ b/packages/models/src/videos/import/video-import-create.model.ts @@ -0,0 +1,9 @@ +import { VideoUpdate } from '../video-update.model.js' + +export interface VideoImportCreate extends VideoUpdate { + targetUrl?: string + magnetUri?: string + torrentfile?: Blob + + channelId: number // Required +} diff --git a/packages/models/src/videos/import/video-import-state.enum.ts b/packages/models/src/videos/import/video-import-state.enum.ts new file mode 100644 index 000000000..475fdbe66 --- /dev/null +++ b/packages/models/src/videos/import/video-import-state.enum.ts @@ -0,0 +1,10 @@ +export const VideoImportState = { + PENDING: 1, + SUCCESS: 2, + FAILED: 3, + REJECTED: 4, + CANCELLED: 5, + PROCESSING: 6 +} as const + +export type VideoImportStateType = typeof VideoImportState[keyof typeof VideoImportState] diff --git a/packages/models/src/videos/import/video-import.model.ts b/packages/models/src/videos/import/video-import.model.ts new file mode 100644 index 000000000..eef23f401 --- /dev/null +++ b/packages/models/src/videos/import/video-import.model.ts @@ -0,0 +1,24 @@ +import { VideoConstant } from '../video-constant.model.js' +import { Video } from '../video.model.js' +import { VideoImportStateType } from './video-import-state.enum.js' + +export interface VideoImport { + id: number + + targetUrl: string + magnetUri: string + torrentName: string + + createdAt: string + updatedAt: string + originallyPublishedAt?: string + state: VideoConstant + error?: string + + video?: Video & { tags: string[] } + + videoChannelSync?: { + id: number + externalChannelUrl: string + } +} diff --git a/packages/models/src/videos/import/videos-import-in-channel-create.model.ts b/packages/models/src/videos/import/videos-import-in-channel-create.model.ts new file mode 100644 index 000000000..fbfef63f8 --- /dev/null +++ b/packages/models/src/videos/import/videos-import-in-channel-create.model.ts @@ -0,0 +1,4 @@ +export interface VideosImportInChannelCreate { + externalChannelUrl: string + videoChannelSyncId?: number +} diff --git a/packages/models/src/videos/index.ts b/packages/models/src/videos/index.ts new file mode 100644 index 000000000..d131212c9 --- /dev/null +++ b/packages/models/src/videos/index.ts @@ -0,0 +1,43 @@ +export * from './blacklist/index.js' +export * from './caption/index.js' +export * from './change-ownership/index.js' +export * from './channel/index.js' +export * from './comment/index.js' +export * from './studio/index.js' +export * from './live/index.js' +export * from './file/index.js' +export * from './import/index.js' +export * from './playlist/index.js' +export * from './rate/index.js' +export * from './stats/index.js' +export * from './transcoding/index.js' +export * from './channel-sync/index.js' + +export * from './nsfw-policy.type.js' + +export * from './storyboard.model.js' +export * from './thumbnail.type.js' + +export * from './video-constant.model.js' +export * from './video-create.model.js' + +export * from './video-privacy.enum.js' +export * from './video-include.enum.js' +export * from './video-rate.type.js' + +export * from './video-schedule-update.model.js' +export * from './video-sort-field.type.js' +export * from './video-state.enum.js' +export * from './video-storage.enum.js' +export * from './video-source.model.js' + +export * from './video-streaming-playlist.model.js' +export * from './video-streaming-playlist.type.js' + +export * from './video-token.model.js' + +export * from './video-update.model.js' +export * from './video-view.model.js' +export * from './video.model.js' +export * from './video-create-result.model.js' +export * from './video-password.model.js' diff --git a/packages/models/src/videos/live/index.ts b/packages/models/src/videos/live/index.ts new file mode 100644 index 000000000..1763eb574 --- /dev/null +++ b/packages/models/src/videos/live/index.ts @@ -0,0 +1,8 @@ +export * from './live-video-create.model.js' +export * from './live-video-error.enum.js' +export * from './live-video-event-payload.model.js' +export * from './live-video-event.type.js' +export * from './live-video-latency-mode.enum.js' +export * from './live-video-session.model.js' +export * from './live-video-update.model.js' +export * from './live-video.model.js' diff --git a/packages/models/src/videos/live/live-video-create.model.ts b/packages/models/src/videos/live/live-video-create.model.ts new file mode 100644 index 000000000..e4e39518c --- /dev/null +++ b/packages/models/src/videos/live/live-video-create.model.ts @@ -0,0 +1,11 @@ +import { VideoCreate } from '../video-create.model.js' +import { VideoPrivacyType } from '../video-privacy.enum.js' +import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js' + +export interface LiveVideoCreate extends VideoCreate { + permanentLive?: boolean + latencyMode?: LiveVideoLatencyModeType + + saveReplay?: boolean + replaySettings?: { privacy: VideoPrivacyType } +} diff --git a/packages/models/src/videos/live/live-video-error.enum.ts b/packages/models/src/videos/live/live-video-error.enum.ts new file mode 100644 index 000000000..cd92a1cff --- /dev/null +++ b/packages/models/src/videos/live/live-video-error.enum.ts @@ -0,0 +1,11 @@ +export const LiveVideoError = { + BAD_SOCKET_HEALTH: 1, + DURATION_EXCEEDED: 2, + QUOTA_EXCEEDED: 3, + FFMPEG_ERROR: 4, + BLACKLISTED: 5, + RUNNER_JOB_ERROR: 6, + RUNNER_JOB_CANCEL: 7 +} as const + +export type LiveVideoErrorType = typeof LiveVideoError[keyof typeof LiveVideoError] diff --git a/packages/models/src/videos/live/live-video-event-payload.model.ts b/packages/models/src/videos/live/live-video-event-payload.model.ts new file mode 100644 index 000000000..507f8d153 --- /dev/null +++ b/packages/models/src/videos/live/live-video-event-payload.model.ts @@ -0,0 +1,7 @@ +import { VideoStateType } from '../video-state.enum.js' + +export interface LiveVideoEventPayload { + state?: VideoStateType + + viewers?: number +} diff --git a/packages/models/src/videos/live/live-video-event.type.ts b/packages/models/src/videos/live/live-video-event.type.ts new file mode 100644 index 000000000..50f794561 --- /dev/null +++ b/packages/models/src/videos/live/live-video-event.type.ts @@ -0,0 +1 @@ +export type LiveVideoEventType = 'state-change' | 'views-change' diff --git a/packages/models/src/videos/live/live-video-latency-mode.enum.ts b/packages/models/src/videos/live/live-video-latency-mode.enum.ts new file mode 100644 index 000000000..6fd8fe8e9 --- /dev/null +++ b/packages/models/src/videos/live/live-video-latency-mode.enum.ts @@ -0,0 +1,7 @@ +export const LiveVideoLatencyMode = { + DEFAULT: 1, + HIGH_LATENCY: 2, + SMALL_LATENCY: 3 +} as const + +export type LiveVideoLatencyModeType = typeof LiveVideoLatencyMode[keyof typeof LiveVideoLatencyMode] diff --git a/packages/models/src/videos/live/live-video-session.model.ts b/packages/models/src/videos/live/live-video-session.model.ts new file mode 100644 index 000000000..8d45bc86a --- /dev/null +++ b/packages/models/src/videos/live/live-video-session.model.ts @@ -0,0 +1,22 @@ +import { VideoPrivacyType } from '../video-privacy.enum.js' +import { LiveVideoErrorType } from './live-video-error.enum.js' + +export interface LiveVideoSession { + id: number + + startDate: string + endDate: string + + error: LiveVideoErrorType + + saveReplay: boolean + endingProcessed: boolean + + replaySettings?: { privacy: VideoPrivacyType } + + replayVideo: { + id: number + uuid: string + shortUUID: string + } +} diff --git a/packages/models/src/videos/live/live-video-update.model.ts b/packages/models/src/videos/live/live-video-update.model.ts new file mode 100644 index 000000000..b4d91e447 --- /dev/null +++ b/packages/models/src/videos/live/live-video-update.model.ts @@ -0,0 +1,9 @@ +import { VideoPrivacyType } from '../video-privacy.enum.js' +import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js' + +export interface LiveVideoUpdate { + permanentLive?: boolean + saveReplay?: boolean + replaySettings?: { privacy: VideoPrivacyType } + latencyMode?: LiveVideoLatencyModeType +} diff --git a/packages/models/src/videos/live/live-video.model.ts b/packages/models/src/videos/live/live-video.model.ts new file mode 100644 index 000000000..3e91f677c --- /dev/null +++ b/packages/models/src/videos/live/live-video.model.ts @@ -0,0 +1,14 @@ +import { VideoPrivacyType } from '../video-privacy.enum.js' +import { LiveVideoLatencyModeType } from './live-video-latency-mode.enum.js' + +export interface LiveVideo { + // If owner + rtmpUrl?: string + rtmpsUrl?: string + streamKey?: string + + saveReplay: boolean + replaySettings?: { privacy: VideoPrivacyType } + permanentLive: boolean + latencyMode: LiveVideoLatencyModeType +} diff --git a/packages/models/src/videos/nsfw-policy.type.ts b/packages/models/src/videos/nsfw-policy.type.ts new file mode 100644 index 000000000..dc0032a14 --- /dev/null +++ b/packages/models/src/videos/nsfw-policy.type.ts @@ -0,0 +1 @@ +export type NSFWPolicyType = 'do_not_list' | 'blur' | 'display' diff --git a/packages/models/src/videos/playlist/index.ts b/packages/models/src/videos/playlist/index.ts new file mode 100644 index 000000000..0e139657a --- /dev/null +++ b/packages/models/src/videos/playlist/index.ts @@ -0,0 +1,12 @@ +export * from './video-exist-in-playlist.model.js' +export * from './video-playlist-create-result.model.js' +export * from './video-playlist-create.model.js' +export * from './video-playlist-element-create-result.model.js' +export * from './video-playlist-element-create.model.js' +export * from './video-playlist-element-update.model.js' +export * from './video-playlist-element.model.js' +export * from './video-playlist-privacy.model.js' +export * from './video-playlist-reorder.model.js' +export * from './video-playlist-type.model.js' +export * from './video-playlist-update.model.js' +export * from './video-playlist.model.js' diff --git a/packages/models/src/videos/playlist/video-exist-in-playlist.model.ts b/packages/models/src/videos/playlist/video-exist-in-playlist.model.ts new file mode 100644 index 000000000..6d06c0f4d --- /dev/null +++ b/packages/models/src/videos/playlist/video-exist-in-playlist.model.ts @@ -0,0 +1,18 @@ +export type VideosExistInPlaylists = { + [videoId: number]: VideoExistInPlaylist[] +} +export type CachedVideosExistInPlaylists = { + [videoId: number]: CachedVideoExistInPlaylist[] +} + +export type CachedVideoExistInPlaylist = { + playlistElementId: number + playlistId: number + startTimestamp?: number + stopTimestamp?: number +} + +export type VideoExistInPlaylist = CachedVideoExistInPlaylist & { + playlistDisplayName: string + playlistShortUUID: string +} diff --git a/packages/models/src/videos/playlist/video-playlist-create-result.model.ts b/packages/models/src/videos/playlist/video-playlist-create-result.model.ts new file mode 100644 index 000000000..cd9b170ae --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-create-result.model.ts @@ -0,0 +1,5 @@ +export interface VideoPlaylistCreateResult { + id: number + uuid: string + shortUUID: string +} diff --git a/packages/models/src/videos/playlist/video-playlist-create.model.ts b/packages/models/src/videos/playlist/video-playlist-create.model.ts new file mode 100644 index 000000000..f9dd1e0d1 --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-create.model.ts @@ -0,0 +1,11 @@ +import { VideoPlaylistPrivacyType } from './video-playlist-privacy.model.js' + +export interface VideoPlaylistCreate { + displayName: string + privacy: VideoPlaylistPrivacyType + + description?: string + videoChannelId?: number + + thumbnailfile?: any +} diff --git a/packages/models/src/videos/playlist/video-playlist-element-create-result.model.ts b/packages/models/src/videos/playlist/video-playlist-element-create-result.model.ts new file mode 100644 index 000000000..dc475e7d8 --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-element-create-result.model.ts @@ -0,0 +1,3 @@ +export interface VideoPlaylistElementCreateResult { + id: number +} diff --git a/packages/models/src/videos/playlist/video-playlist-element-create.model.ts b/packages/models/src/videos/playlist/video-playlist-element-create.model.ts new file mode 100644 index 000000000..c31702892 --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-element-create.model.ts @@ -0,0 +1,6 @@ +export interface VideoPlaylistElementCreate { + videoId: number + + startTimestamp?: number + stopTimestamp?: number +} diff --git a/packages/models/src/videos/playlist/video-playlist-element-update.model.ts b/packages/models/src/videos/playlist/video-playlist-element-update.model.ts new file mode 100644 index 000000000..15a30fbdc --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-element-update.model.ts @@ -0,0 +1,4 @@ +export interface VideoPlaylistElementUpdate { + startTimestamp?: number + stopTimestamp?: number +} diff --git a/packages/models/src/videos/playlist/video-playlist-element.model.ts b/packages/models/src/videos/playlist/video-playlist-element.model.ts new file mode 100644 index 000000000..a4711f919 --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-element.model.ts @@ -0,0 +1,21 @@ +import { Video } from '../video.model.js' + +export const VideoPlaylistElementType = { + REGULAR: 0, + DELETED: 1, + PRIVATE: 2, + UNAVAILABLE: 3 // Blacklisted, blocked by the user/instance, NSFW... +} as const + +export type VideoPlaylistElementType_Type = typeof VideoPlaylistElementType[keyof typeof VideoPlaylistElementType] + +export interface VideoPlaylistElement { + id: number + position: number + startTimestamp: number + stopTimestamp: number + + type: VideoPlaylistElementType_Type + + video?: Video +} diff --git a/packages/models/src/videos/playlist/video-playlist-privacy.model.ts b/packages/models/src/videos/playlist/video-playlist-privacy.model.ts new file mode 100644 index 000000000..23f6a1a16 --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-privacy.model.ts @@ -0,0 +1,7 @@ +export const VideoPlaylistPrivacy = { + PUBLIC: 1, + UNLISTED: 2, + PRIVATE: 3 +} as const + +export type VideoPlaylistPrivacyType = typeof VideoPlaylistPrivacy[keyof typeof VideoPlaylistPrivacy] diff --git a/packages/models/src/videos/playlist/video-playlist-reorder.model.ts b/packages/models/src/videos/playlist/video-playlist-reorder.model.ts new file mode 100644 index 000000000..63ec714c5 --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-reorder.model.ts @@ -0,0 +1,5 @@ +export interface VideoPlaylistReorder { + startPosition: number + insertAfterPosition: number + reorderLength?: number +} diff --git a/packages/models/src/videos/playlist/video-playlist-type.model.ts b/packages/models/src/videos/playlist/video-playlist-type.model.ts new file mode 100644 index 000000000..183439f98 --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-type.model.ts @@ -0,0 +1,6 @@ +export const VideoPlaylistType = { + REGULAR: 1, + WATCH_LATER: 2 +} as const + +export type VideoPlaylistType_Type = typeof VideoPlaylistType[keyof typeof VideoPlaylistType] diff --git a/packages/models/src/videos/playlist/video-playlist-update.model.ts b/packages/models/src/videos/playlist/video-playlist-update.model.ts new file mode 100644 index 000000000..ed536367e --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist-update.model.ts @@ -0,0 +1,10 @@ +import { VideoPlaylistPrivacyType } from './video-playlist-privacy.model.js' + +export interface VideoPlaylistUpdate { + displayName?: string + privacy?: VideoPlaylistPrivacyType + + description?: string + videoChannelId?: number + thumbnailfile?: any +} diff --git a/packages/models/src/videos/playlist/video-playlist.model.ts b/packages/models/src/videos/playlist/video-playlist.model.ts new file mode 100644 index 000000000..4261aac25 --- /dev/null +++ b/packages/models/src/videos/playlist/video-playlist.model.ts @@ -0,0 +1,35 @@ +import { AccountSummary } from '../../actors/index.js' +import { VideoChannelSummary } from '../channel/index.js' +import { VideoConstant } from '../video-constant.model.js' +import { VideoPlaylistPrivacyType } from './video-playlist-privacy.model.js' +import { VideoPlaylistType_Type } from './video-playlist-type.model.js' + +export interface VideoPlaylist { + id: number + uuid: string + shortUUID: string + + isLocal: boolean + + url: string + + displayName: string + description: string + privacy: VideoConstant + + thumbnailPath: string + thumbnailUrl?: string + + videosLength: number + + type: VideoConstant + + embedPath: string + embedUrl?: string + + createdAt: Date | string + updatedAt: Date | string + + ownerAccount: AccountSummary + videoChannel?: VideoChannelSummary +} diff --git a/packages/models/src/videos/rate/account-video-rate.model.ts b/packages/models/src/videos/rate/account-video-rate.model.ts new file mode 100644 index 000000000..d19ccdbdd --- /dev/null +++ b/packages/models/src/videos/rate/account-video-rate.model.ts @@ -0,0 +1,7 @@ +import { UserVideoRateType } from './user-video-rate.type.js' +import { Video } from '../video.model.js' + +export interface AccountVideoRate { + video: Video + rating: UserVideoRateType +} diff --git a/packages/models/src/videos/rate/index.ts b/packages/models/src/videos/rate/index.ts new file mode 100644 index 000000000..ecbe3523d --- /dev/null +++ b/packages/models/src/videos/rate/index.ts @@ -0,0 +1,5 @@ + +export * from './user-video-rate-update.model.js' +export * from './user-video-rate.model.js' +export * from './account-video-rate.model.js' +export * from './user-video-rate.type.js' diff --git a/packages/models/src/videos/rate/user-video-rate-update.model.ts b/packages/models/src/videos/rate/user-video-rate-update.model.ts new file mode 100644 index 000000000..8ee1e78ca --- /dev/null +++ b/packages/models/src/videos/rate/user-video-rate-update.model.ts @@ -0,0 +1,5 @@ +import { UserVideoRateType } from './user-video-rate.type.js' + +export interface UserVideoRateUpdate { + rating: UserVideoRateType +} diff --git a/packages/models/src/videos/rate/user-video-rate.model.ts b/packages/models/src/videos/rate/user-video-rate.model.ts new file mode 100644 index 000000000..344cf9a68 --- /dev/null +++ b/packages/models/src/videos/rate/user-video-rate.model.ts @@ -0,0 +1,6 @@ +import { UserVideoRateType } from './user-video-rate.type.js' + +export interface UserVideoRate { + videoId: number + rating: UserVideoRateType +} diff --git a/packages/models/src/videos/rate/user-video-rate.type.ts b/packages/models/src/videos/rate/user-video-rate.type.ts new file mode 100644 index 000000000..a4d9c7e39 --- /dev/null +++ b/packages/models/src/videos/rate/user-video-rate.type.ts @@ -0,0 +1 @@ +export type UserVideoRateType = 'like' | 'dislike' | 'none' diff --git a/packages/models/src/videos/stats/index.ts b/packages/models/src/videos/stats/index.ts new file mode 100644 index 000000000..7187cac26 --- /dev/null +++ b/packages/models/src/videos/stats/index.ts @@ -0,0 +1,6 @@ +export * from './video-stats-overall-query.model.js' +export * from './video-stats-overall.model.js' +export * from './video-stats-retention.model.js' +export * from './video-stats-timeserie-query.model.js' +export * from './video-stats-timeserie-metric.type.js' +export * from './video-stats-timeserie.model.js' diff --git a/packages/models/src/videos/stats/video-stats-overall-query.model.ts b/packages/models/src/videos/stats/video-stats-overall-query.model.ts new file mode 100644 index 000000000..6b4c2164f --- /dev/null +++ b/packages/models/src/videos/stats/video-stats-overall-query.model.ts @@ -0,0 +1,4 @@ +export interface VideoStatsOverallQuery { + startDate?: string + endDate?: string +} diff --git a/packages/models/src/videos/stats/video-stats-overall.model.ts b/packages/models/src/videos/stats/video-stats-overall.model.ts new file mode 100644 index 000000000..54b57798f --- /dev/null +++ b/packages/models/src/videos/stats/video-stats-overall.model.ts @@ -0,0 +1,14 @@ +export interface VideoStatsOverall { + averageWatchTime: number + totalWatchTime: number + + totalViewers: number + + viewersPeak: number + viewersPeakDate: string + + countries: { + isoCode: string + viewers: number + }[] +} diff --git a/packages/models/src/videos/stats/video-stats-retention.model.ts b/packages/models/src/videos/stats/video-stats-retention.model.ts new file mode 100644 index 000000000..e494888ed --- /dev/null +++ b/packages/models/src/videos/stats/video-stats-retention.model.ts @@ -0,0 +1,6 @@ +export interface VideoStatsRetention { + data: { + second: number + retentionPercent: number + }[] +} diff --git a/packages/models/src/videos/stats/video-stats-timeserie-metric.type.ts b/packages/models/src/videos/stats/video-stats-timeserie-metric.type.ts new file mode 100644 index 000000000..fc268d083 --- /dev/null +++ b/packages/models/src/videos/stats/video-stats-timeserie-metric.type.ts @@ -0,0 +1 @@ +export type VideoStatsTimeserieMetric = 'viewers' | 'aggregateWatchTime' diff --git a/packages/models/src/videos/stats/video-stats-timeserie-query.model.ts b/packages/models/src/videos/stats/video-stats-timeserie-query.model.ts new file mode 100644 index 000000000..f3a8430e1 --- /dev/null +++ b/packages/models/src/videos/stats/video-stats-timeserie-query.model.ts @@ -0,0 +1,4 @@ +export interface VideoStatsTimeserieQuery { + startDate?: string + endDate?: string +} diff --git a/packages/models/src/videos/stats/video-stats-timeserie.model.ts b/packages/models/src/videos/stats/video-stats-timeserie.model.ts new file mode 100644 index 000000000..4a0e208df --- /dev/null +++ b/packages/models/src/videos/stats/video-stats-timeserie.model.ts @@ -0,0 +1,8 @@ +export interface VideoStatsTimeserie { + groupInterval: string + + data: { + date: string + value: number + }[] +} diff --git a/packages/models/src/videos/storyboard.model.ts b/packages/models/src/videos/storyboard.model.ts new file mode 100644 index 000000000..c92c81f09 --- /dev/null +++ b/packages/models/src/videos/storyboard.model.ts @@ -0,0 +1,11 @@ +export interface Storyboard { + storyboardPath: string + + totalHeight: number + totalWidth: number + + spriteHeight: number + spriteWidth: number + + spriteDuration: number +} diff --git a/packages/models/src/videos/studio/index.ts b/packages/models/src/videos/studio/index.ts new file mode 100644 index 000000000..0d8ad3227 --- /dev/null +++ b/packages/models/src/videos/studio/index.ts @@ -0,0 +1 @@ +export * from './video-studio-create-edit.model.js' diff --git a/packages/models/src/videos/studio/video-studio-create-edit.model.ts b/packages/models/src/videos/studio/video-studio-create-edit.model.ts new file mode 100644 index 000000000..5e8296dc9 --- /dev/null +++ b/packages/models/src/videos/studio/video-studio-create-edit.model.ts @@ -0,0 +1,60 @@ +export interface VideoStudioCreateEdition { + tasks: VideoStudioTask[] +} + +export type VideoStudioTask = + VideoStudioTaskCut | + VideoStudioTaskIntro | + VideoStudioTaskOutro | + VideoStudioTaskWatermark + +export interface VideoStudioTaskCut { + name: 'cut' + + options: { + start?: number + end?: number + } +} + +export interface VideoStudioTaskIntro { + name: 'add-intro' + + options: { + file: Blob | string + } +} + +export interface VideoStudioTaskOutro { + name: 'add-outro' + + options: { + file: Blob | string + } +} + +export interface VideoStudioTaskWatermark { + name: 'add-watermark' + + options: { + file: Blob | string + } +} + +// --------------------------------------------------------------------------- + +export function isVideoStudioTaskIntro (v: VideoStudioTask): v is VideoStudioTaskIntro { + return v.name === 'add-intro' +} + +export function isVideoStudioTaskOutro (v: VideoStudioTask): v is VideoStudioTaskOutro { + return v.name === 'add-outro' +} + +export function isVideoStudioTaskWatermark (v: VideoStudioTask): v is VideoStudioTaskWatermark { + return v.name === 'add-watermark' +} + +export function hasVideoStudioTaskFile (v: VideoStudioTask): v is VideoStudioTaskIntro | VideoStudioTaskOutro | VideoStudioTaskWatermark { + return isVideoStudioTaskIntro(v) || isVideoStudioTaskOutro(v) || isVideoStudioTaskWatermark(v) +} diff --git a/packages/models/src/videos/thumbnail.type.ts b/packages/models/src/videos/thumbnail.type.ts new file mode 100644 index 000000000..0cb7483ec --- /dev/null +++ b/packages/models/src/videos/thumbnail.type.ts @@ -0,0 +1,6 @@ +export const ThumbnailType = { + MINIATURE: 1, + PREVIEW: 2 +} as const + +export type ThumbnailType_Type = typeof ThumbnailType[keyof typeof ThumbnailType] diff --git a/packages/models/src/videos/transcoding/index.ts b/packages/models/src/videos/transcoding/index.ts new file mode 100644 index 000000000..e1d931bd5 --- /dev/null +++ b/packages/models/src/videos/transcoding/index.ts @@ -0,0 +1,3 @@ +export * from './video-transcoding-create.model.js' +export * from './video-transcoding-fps.model.js' +export * from './video-transcoding.model.js' diff --git a/packages/models/src/videos/transcoding/video-transcoding-create.model.ts b/packages/models/src/videos/transcoding/video-transcoding-create.model.ts new file mode 100644 index 000000000..6c2dbefa6 --- /dev/null +++ b/packages/models/src/videos/transcoding/video-transcoding-create.model.ts @@ -0,0 +1,5 @@ +export interface VideoTranscodingCreate { + transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7 + + forceTranscoding?: boolean // Default false +} diff --git a/packages/models/src/videos/transcoding/video-transcoding-fps.model.ts b/packages/models/src/videos/transcoding/video-transcoding-fps.model.ts new file mode 100644 index 000000000..9a330ac94 --- /dev/null +++ b/packages/models/src/videos/transcoding/video-transcoding-fps.model.ts @@ -0,0 +1,9 @@ +export type VideoTranscodingFPS = { + MIN: number + STANDARD: number[] + HD_STANDARD: number[] + AUDIO_MERGE: number + AVERAGE: number + MAX: number + KEEP_ORIGIN_FPS_RESOLUTION_MIN: number +} diff --git a/packages/models/src/videos/transcoding/video-transcoding.model.ts b/packages/models/src/videos/transcoding/video-transcoding.model.ts new file mode 100644 index 000000000..e2c2a56e5 --- /dev/null +++ b/packages/models/src/videos/transcoding/video-transcoding.model.ts @@ -0,0 +1,65 @@ +// Types used by plugins and ffmpeg-utils + +export type EncoderOptionsBuilderParams = { + input: string + + resolution: number + + // If PeerTube applies a filter, transcoding profile must not copy input stream + canCopyAudio: boolean + canCopyVideo: boolean + + fps: number + + // Could be undefined if we could not get input bitrate (some RTMP streams for example) + inputBitrate: number + inputRatio: number + + // For lives + streamNum?: number +} + +export type EncoderOptionsBuilder = (params: EncoderOptionsBuilderParams) => Promise | EncoderOptions + +export interface EncoderOptions { + copy?: boolean // Copy stream? Default to false + + scaleFilter?: { + name: string + } + + inputOptions?: string[] + outputOptions?: string[] +} + +// All our encoders + +export interface EncoderProfile { + [ profile: string ]: T + + default: T +} + +export type AvailableEncoders = { + available: { + live: { + [ encoder: string ]: EncoderProfile + } + + vod: { + [ encoder: string ]: EncoderProfile + } + } + + encodersToTry: { + vod: { + video: string[] + audio: string[] + } + + live: { + video: string[] + audio: string[] + } + } +} diff --git a/packages/models/src/videos/video-constant.model.ts b/packages/models/src/videos/video-constant.model.ts new file mode 100644 index 000000000..353a29535 --- /dev/null +++ b/packages/models/src/videos/video-constant.model.ts @@ -0,0 +1,5 @@ +export interface VideoConstant { + id: T + label: string + description?: string +} diff --git a/packages/models/src/videos/video-create-result.model.ts b/packages/models/src/videos/video-create-result.model.ts new file mode 100644 index 000000000..a9f8e25a0 --- /dev/null +++ b/packages/models/src/videos/video-create-result.model.ts @@ -0,0 +1,5 @@ +export interface VideoCreateResult { + id: number + uuid: string + shortUUID: string +} diff --git a/packages/models/src/videos/video-create.model.ts b/packages/models/src/videos/video-create.model.ts new file mode 100644 index 000000000..472201211 --- /dev/null +++ b/packages/models/src/videos/video-create.model.ts @@ -0,0 +1,25 @@ +import { VideoPrivacyType } from './video-privacy.enum.js' +import { VideoScheduleUpdate } from './video-schedule-update.model.js' + +export interface VideoCreate { + name: string + channelId: number + + category?: number + licence?: number + language?: string + description?: string + support?: string + nsfw?: boolean + waitTranscoding?: boolean + tags?: string[] + commentsEnabled?: boolean + downloadEnabled?: boolean + privacy: VideoPrivacyType + scheduleUpdate?: VideoScheduleUpdate + originallyPublishedAt?: Date | string + videoPasswords?: string[] + + thumbnailfile?: Blob | string + previewfile?: Blob | string +} diff --git a/packages/models/src/videos/video-include.enum.ts b/packages/models/src/videos/video-include.enum.ts new file mode 100644 index 000000000..7d88a6890 --- /dev/null +++ b/packages/models/src/videos/video-include.enum.ts @@ -0,0 +1,10 @@ +export const VideoInclude = { + NONE: 0, + NOT_PUBLISHED_STATE: 1 << 0, + BLACKLISTED: 1 << 1, + BLOCKED_OWNER: 1 << 2, + FILES: 1 << 3, + CAPTIONS: 1 << 4 +} as const + +export type VideoIncludeType = typeof VideoInclude[keyof typeof VideoInclude] diff --git a/packages/models/src/videos/video-password.model.ts b/packages/models/src/videos/video-password.model.ts new file mode 100644 index 000000000..c0280b9b9 --- /dev/null +++ b/packages/models/src/videos/video-password.model.ts @@ -0,0 +1,7 @@ +export interface VideoPassword { + id: number + password: string + videoId: number + createdAt: Date | string + updatedAt: Date | string +} diff --git a/packages/models/src/videos/video-privacy.enum.ts b/packages/models/src/videos/video-privacy.enum.ts new file mode 100644 index 000000000..cbcc91b3f --- /dev/null +++ b/packages/models/src/videos/video-privacy.enum.ts @@ -0,0 +1,9 @@ +export const VideoPrivacy = { + PUBLIC: 1, + UNLISTED: 2, + PRIVATE: 3, + INTERNAL: 4, + PASSWORD_PROTECTED: 5 +} as const + +export type VideoPrivacyType = typeof VideoPrivacy[keyof typeof VideoPrivacy] diff --git a/packages/models/src/videos/video-rate.type.ts b/packages/models/src/videos/video-rate.type.ts new file mode 100644 index 000000000..d48774a4b --- /dev/null +++ b/packages/models/src/videos/video-rate.type.ts @@ -0,0 +1 @@ +export type VideoRateType = 'like' | 'dislike' diff --git a/packages/models/src/videos/video-schedule-update.model.ts b/packages/models/src/videos/video-schedule-update.model.ts new file mode 100644 index 000000000..2e6a5551d --- /dev/null +++ b/packages/models/src/videos/video-schedule-update.model.ts @@ -0,0 +1,7 @@ +import { VideoPrivacy } from './video-privacy.enum.js' + +export interface VideoScheduleUpdate { + updateAt: Date | string + // Cannot schedule an update to PRIVATE + privacy?: typeof VideoPrivacy.PUBLIC | typeof VideoPrivacy.UNLISTED | typeof VideoPrivacy.INTERNAL +} diff --git a/packages/models/src/videos/video-sort-field.type.ts b/packages/models/src/videos/video-sort-field.type.ts new file mode 100644 index 000000000..7fa07fa73 --- /dev/null +++ b/packages/models/src/videos/video-sort-field.type.ts @@ -0,0 +1,13 @@ +export type VideoSortField = + 'name' | '-name' | + 'duration' | '-duration' | + 'publishedAt' | '-publishedAt' | + 'originallyPublishedAt' | '-originallyPublishedAt' | + 'createdAt' | '-createdAt' | + 'views' | '-views' | + 'likes' | '-likes' | + + // trending sorts + 'trending' | '-trending' | + 'hot' | '-hot' | + 'best' | '-best' diff --git a/packages/models/src/videos/video-source.model.ts b/packages/models/src/videos/video-source.model.ts new file mode 100644 index 000000000..bf4ad2453 --- /dev/null +++ b/packages/models/src/videos/video-source.model.ts @@ -0,0 +1,4 @@ +export interface VideoSource { + filename: string + createdAt: string | Date +} diff --git a/packages/models/src/videos/video-state.enum.ts b/packages/models/src/videos/video-state.enum.ts new file mode 100644 index 000000000..ae7c6a0c4 --- /dev/null +++ b/packages/models/src/videos/video-state.enum.ts @@ -0,0 +1,13 @@ +export const VideoState = { + PUBLISHED: 1, + TO_TRANSCODE: 2, + TO_IMPORT: 3, + WAITING_FOR_LIVE: 4, + LIVE_ENDED: 5, + TO_MOVE_TO_EXTERNAL_STORAGE: 6, + TRANSCODING_FAILED: 7, + TO_MOVE_TO_EXTERNAL_STORAGE_FAILED: 8, + TO_EDIT: 9 +} as const + +export type VideoStateType = typeof VideoState[keyof typeof VideoState] diff --git a/packages/models/src/videos/video-storage.enum.ts b/packages/models/src/videos/video-storage.enum.ts new file mode 100644 index 000000000..de5c92e0d --- /dev/null +++ b/packages/models/src/videos/video-storage.enum.ts @@ -0,0 +1,6 @@ +export const VideoStorage = { + FILE_SYSTEM: 0, + OBJECT_STORAGE: 1 +} as const + +export type VideoStorageType = typeof VideoStorage[keyof typeof VideoStorage] diff --git a/packages/models/src/videos/video-streaming-playlist.model.ts b/packages/models/src/videos/video-streaming-playlist.model.ts new file mode 100644 index 000000000..80aa70e3c --- /dev/null +++ b/packages/models/src/videos/video-streaming-playlist.model.ts @@ -0,0 +1,15 @@ +import { VideoFile } from './file/index.js' +import { VideoStreamingPlaylistType_Type } from './video-streaming-playlist.type.js' + +export interface VideoStreamingPlaylist { + id: number + type: VideoStreamingPlaylistType_Type + playlistUrl: string + segmentsSha256Url: string + + redundancies: { + baseUrl: string + }[] + + files: VideoFile[] +} diff --git a/packages/models/src/videos/video-streaming-playlist.type.ts b/packages/models/src/videos/video-streaming-playlist.type.ts new file mode 100644 index 000000000..07a2c207f --- /dev/null +++ b/packages/models/src/videos/video-streaming-playlist.type.ts @@ -0,0 +1,5 @@ +export const VideoStreamingPlaylistType = { + HLS: 1 +} as const + +export type VideoStreamingPlaylistType_Type = typeof VideoStreamingPlaylistType[keyof typeof VideoStreamingPlaylistType] diff --git a/packages/models/src/videos/video-token.model.ts b/packages/models/src/videos/video-token.model.ts new file mode 100644 index 000000000..aefea565f --- /dev/null +++ b/packages/models/src/videos/video-token.model.ts @@ -0,0 +1,6 @@ +export interface VideoToken { + files: { + token: string + expires: string | Date + } +} diff --git a/packages/models/src/videos/video-update.model.ts b/packages/models/src/videos/video-update.model.ts new file mode 100644 index 000000000..8af298160 --- /dev/null +++ b/packages/models/src/videos/video-update.model.ts @@ -0,0 +1,25 @@ +import { VideoPrivacyType } from './video-privacy.enum.js' +import { VideoScheduleUpdate } from './video-schedule-update.model.js' + +export interface VideoUpdate { + name?: string + category?: number + licence?: number + language?: string + description?: string + support?: string + privacy?: VideoPrivacyType + tags?: string[] + commentsEnabled?: boolean + downloadEnabled?: boolean + nsfw?: boolean + waitTranscoding?: boolean + channelId?: number + thumbnailfile?: Blob + previewfile?: Blob + scheduleUpdate?: VideoScheduleUpdate + originallyPublishedAt?: Date | string + videoPasswords?: string[] + + pluginData?: any +} diff --git a/packages/models/src/videos/video-view.model.ts b/packages/models/src/videos/video-view.model.ts new file mode 100644 index 000000000..f61211104 --- /dev/null +++ b/packages/models/src/videos/video-view.model.ts @@ -0,0 +1,6 @@ +export type VideoViewEvent = 'seek' + +export interface VideoView { + currentTime: number + viewEvent?: VideoViewEvent +} diff --git a/packages/models/src/videos/video.model.ts b/packages/models/src/videos/video.model.ts new file mode 100644 index 000000000..a750e220d --- /dev/null +++ b/packages/models/src/videos/video.model.ts @@ -0,0 +1,99 @@ +import { Account, AccountSummary } from '../actors/index.js' +import { VideoChannel, VideoChannelSummary } from './channel/video-channel.model.js' +import { VideoFile } from './file/index.js' +import { VideoConstant } from './video-constant.model.js' +import { VideoPrivacyType } from './video-privacy.enum.js' +import { VideoScheduleUpdate } from './video-schedule-update.model.js' +import { VideoStateType } from './video-state.enum.js' +import { VideoStreamingPlaylist } from './video-streaming-playlist.model.js' + +export interface Video extends Partial { + id: number + uuid: string + shortUUID: string + + createdAt: Date | string + updatedAt: Date | string + publishedAt: Date | string + originallyPublishedAt: Date | string + category: VideoConstant + licence: VideoConstant + language: VideoConstant + privacy: VideoConstant + + // Deprecated in 5.0 in favour of truncatedDescription + description: string + truncatedDescription: string + + duration: number + isLocal: boolean + name: string + + isLive: boolean + + thumbnailPath: string + thumbnailUrl?: string + + previewPath: string + previewUrl?: string + + embedPath: string + embedUrl?: string + + url: string + + views: number + viewers: number + + likes: number + dislikes: number + nsfw: boolean + + account: AccountSummary + channel: VideoChannelSummary + + userHistory?: { + currentTime: number + } + + pluginData?: any +} + +// Not included by default, needs query params +export interface VideoAdditionalAttributes { + waitTranscoding: boolean + state: VideoConstant + scheduledUpdate: VideoScheduleUpdate + + blacklisted: boolean + blacklistedReason: string + + blockedOwner: boolean + blockedServer: boolean + + files: VideoFile[] + streamingPlaylists: VideoStreamingPlaylist[] +} + +export interface VideoDetails extends Video { + // Deprecated in 5.0 + descriptionPath: string + + support: string + channel: VideoChannel + account: Account + tags: string[] + commentsEnabled: boolean + downloadEnabled: boolean + + // Not optional in details (unlike in parent Video) + waitTranscoding: boolean + state: VideoConstant + + trackerUrls: string[] + + files: VideoFile[] + streamingPlaylists: VideoStreamingPlaylist[] + + inputFileUpdatedAt: string | Date +} diff --git a/packages/models/tsconfig.json b/packages/models/tsconfig.json new file mode 100644 index 000000000..58fa2330b --- /dev/null +++ b/packages/models/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + } +} diff --git a/packages/models/tsconfig.types.json b/packages/models/tsconfig.types.json new file mode 100644 index 000000000..997161c21 --- /dev/null +++ b/packages/models/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../types-generator/dist/peertube-models", + "tsBuildInfoFile": "../types-generator/dist/peertube-models/.tsbuildinfo", + "stripInternal": true, + "removeComments": false, + "emitDeclarationOnly": true + } +} diff --git a/packages/node-utils/package.json b/packages/node-utils/package.json new file mode 100644 index 000000000..cd7d8ac80 --- /dev/null +++ b/packages/node-utils/package.json @@ -0,0 +1,19 @@ +{ + "name": "@peertube/peertube-node-utils", + "private": true, + "version": "0.0.0", + "main": "dist/index.js", + "files": [ "dist" ], + "exports": { + "types": "./dist/index.d.ts", + "peertube:tsx": "./src/index.ts", + "default": "./dist/index.js" + }, + "type": "module", + "devDependencies": {}, + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "dependencies": {} +} diff --git a/packages/node-utils/src/crypto.ts b/packages/node-utils/src/crypto.ts new file mode 100644 index 000000000..1a583f1a0 --- /dev/null +++ b/packages/node-utils/src/crypto.ts @@ -0,0 +1,20 @@ +import { BinaryToTextEncoding, createHash } from 'crypto' + +function sha256 (str: string | Buffer, encoding: BinaryToTextEncoding = 'hex') { + return createHash('sha256').update(str).digest(encoding) +} + +function sha1 (str: string | Buffer, encoding: BinaryToTextEncoding = 'hex') { + return createHash('sha1').update(str).digest(encoding) +} + +// high excluded +function randomInt (low: number, high: number) { + return Math.floor(Math.random() * (high - low) + low) +} + +export { + randomInt, + sha256, + sha1 +} diff --git a/packages/node-utils/src/env.ts b/packages/node-utils/src/env.ts new file mode 100644 index 000000000..1a28f509e --- /dev/null +++ b/packages/node-utils/src/env.ts @@ -0,0 +1,58 @@ +export function parallelTests () { + return process.env.MOCHA_PARALLEL === 'true' +} + +export function isGithubCI () { + return !!process.env.GITHUB_WORKSPACE +} + +export function areHttpImportTestsDisabled () { + const disabled = process.env.DISABLE_HTTP_IMPORT_TESTS === 'true' + + if (disabled) console.log('DISABLE_HTTP_IMPORT_TESTS env set to "true" so import tests are disabled') + + return disabled +} + +export function areMockObjectStorageTestsDisabled () { + const disabled = process.env.ENABLE_OBJECT_STORAGE_TESTS !== 'true' + + if (disabled) console.log('ENABLE_OBJECT_STORAGE_TESTS env is not set to "true" so object storage tests are disabled') + + return disabled +} + +export function areScalewayObjectStorageTestsDisabled () { + if (areMockObjectStorageTestsDisabled()) return true + + const enabled = process.env.OBJECT_STORAGE_SCALEWAY_KEY_ID && process.env.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY + if (!enabled) { + console.log( + 'OBJECT_STORAGE_SCALEWAY_KEY_ID and/or OBJECT_STORAGE_SCALEWAY_ACCESS_KEY are not set, so scaleway object storage tests are disabled' + ) + + return true + } + + return false +} + +export function isTestInstance () { + return process.env.NODE_ENV === 'test' +} + +export function isDevInstance () { + return process.env.NODE_ENV === 'dev' +} + +export function isTestOrDevInstance () { + return isTestInstance() || isDevInstance() +} + +export function isProdInstance () { + return process.env.NODE_ENV === 'production' +} + +export function getAppNumber () { + return process.env.NODE_APP_INSTANCE || '' +} diff --git a/packages/node-utils/src/file.ts b/packages/node-utils/src/file.ts new file mode 100644 index 000000000..89cf5fe0f --- /dev/null +++ b/packages/node-utils/src/file.ts @@ -0,0 +1,11 @@ +import { stat } from 'fs/promises' + +async function getFileSize (path: string) { + const stats = await stat(path) + + return stats.size +} + +export { + getFileSize +} diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts new file mode 100644 index 000000000..89f22e7d3 --- /dev/null +++ b/packages/node-utils/src/index.ts @@ -0,0 +1,5 @@ +export * from './crypto.js' +export * from './env.js' +export * from './file.js' +export * from './path.js' +export * from './uuid.js' diff --git a/packages/node-utils/src/path.ts b/packages/node-utils/src/path.ts new file mode 100644 index 000000000..1d569833e --- /dev/null +++ b/packages/node-utils/src/path.ts @@ -0,0 +1,50 @@ +import { basename, extname, isAbsolute, join, resolve } from 'path' +import { fileURLToPath } from 'url' + +let rootPath: string + +export function currentDir (metaUrl: string) { + return resolve(fileURLToPath(metaUrl), '..') +} + +export function root (metaUrl?: string) { + if (rootPath) return rootPath + + if (!metaUrl) { + metaUrl = import.meta.url + + const filename = basename(metaUrl) === 'path.js' || basename(metaUrl) === 'path.ts' + if (!filename) throw new Error('meta url must be specified as this file has been bundled in another one') + } + + rootPath = currentDir(metaUrl) + + if (basename(rootPath) === 'src' || basename(rootPath) === 'dist') rootPath = resolve(rootPath, '..') + if ([ 'node-utils', 'peertube-cli', 'peertube-runner' ].includes(basename(rootPath))) rootPath = resolve(rootPath, '..') + if ([ 'packages', 'apps' ].includes(basename(rootPath))) rootPath = resolve(rootPath, '..') + if (basename(rootPath) === 'dist') rootPath = resolve(rootPath, '..') + + return rootPath +} + +export function buildPath (path: string) { + if (isAbsolute(path)) return path + + return join(root(), path) +} + +export function getLowercaseExtension (filename: string) { + const ext = extname(filename) || '' + + return ext.toLowerCase() +} + +export function buildAbsoluteFixturePath (path: string, customCIPath = false) { + if (isAbsolute(path)) return path + + if (customCIPath && process.env.GITHUB_WORKSPACE) { + return join(process.env.GITHUB_WORKSPACE, 'fixtures', path) + } + + return join(root(), 'packages', 'tests', 'fixtures', path) +} diff --git a/packages/node-utils/src/uuid.ts b/packages/node-utils/src/uuid.ts new file mode 100644 index 000000000..f158ec487 --- /dev/null +++ b/packages/node-utils/src/uuid.ts @@ -0,0 +1,32 @@ +import short from 'short-uuid' + +const translator = short() + +function buildUUID () { + return short.uuid() +} + +function uuidToShort (uuid: string) { + if (!uuid) return uuid + + return translator.fromUUID(uuid) +} + +function shortToUUID (shortUUID: string) { + if (!shortUUID) return shortUUID + + return translator.toUUID(shortUUID) +} + +function isShortUUID (value: string) { + if (!value) return false + + return value.length === translator.maxLength +} + +export { + buildUUID, + uuidToShort, + shortToUUID, + isShortUUID +} diff --git a/packages/node-utils/tsconfig.json b/packages/node-utils/tsconfig.json new file mode 100644 index 000000000..58fa2330b --- /dev/null +++ b/packages/node-utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + } +} diff --git a/packages/peertube-runner/.gitignore b/packages/peertube-runner/.gitignore deleted file mode 100644 index 6426ab063..000000000 --- a/packages/peertube-runner/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -dist -meta.json diff --git a/packages/peertube-runner/.npmignore b/packages/peertube-runner/.npmignore deleted file mode 100644 index f38d9947c..000000000 --- a/packages/peertube-runner/.npmignore +++ /dev/null @@ -1,6 +0,0 @@ -register -server -shared -meta.json -peertube-runner.ts -tsconfig.json diff --git a/packages/peertube-runner/README.md b/packages/peertube-runner/README.md deleted file mode 100644 index 84974bb80..000000000 --- a/packages/peertube-runner/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# PeerTube runner - -Runner program to execute jobs (transcoding...) of remote PeerTube instances. - -Commands below has to be run at the root of PeerTube git repository. - -## Develop - -```bash -npm run dev:peertube-runner -``` - -## Build - -```bash -npm run build:peertube-runner -``` - -## Run - -```bash -node packages/peertube-runner/dist/peertube-runner.js --help -``` - -## Publish on NPM - -```bash -(cd packages/peertube-runner && npm version patch) && npm run build:peertube-runner && (cd packages/peertube-runner && npm publish --access=public) -``` diff --git a/packages/peertube-runner/package.json b/packages/peertube-runner/package.json deleted file mode 100644 index 1c525691a..000000000 --- a/packages/peertube-runner/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@peertube/peertube-runner", - "version": "0.0.5", - "main": "dist/peertube-runner.js", - "bin": "dist/peertube-runner.js", - "license": "AGPL-3.0", - "dependencies": {}, - "devDependencies": { - "@commander-js/extra-typings": "^10.0.3", - "@iarna/toml": "^2.2.5", - "env-paths": "^3.0.0", - "esbuild": "^0.17.15", - "net-ipc": "^2.0.1", - "pino": "^8.11.0", - "pino-pretty": "^10.0.0" - } -} diff --git a/packages/peertube-runner/peertube-runner.ts b/packages/peertube-runner/peertube-runner.ts deleted file mode 100644 index 32586c4d9..000000000 --- a/packages/peertube-runner/peertube-runner.ts +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node - -import { Command, InvalidArgumentError } from '@commander-js/extra-typings' -import { listRegistered, registerRunner, unregisterRunner } from './register' -import { RunnerServer } from './server' -import { ConfigManager, logger } from './shared' - -const packageJSON = require('./package.json') - -const program = new Command() - .version(packageJSON.version) - .option( - '--id ', - 'Runner server id, so you can run multiple PeerTube server runners with different configurations on the same machine', - 'default' - ) - .option('--verbose', 'Run in verbose mode') - .hook('preAction', thisCommand => { - const options = thisCommand.opts() - - ConfigManager.Instance.init(options.id) - - if (options.verbose === true) { - logger.level = 'debug' - } - }) - -program.command('server') - .description('Run in server mode, to execute remote jobs of registered PeerTube instances') - .action(async () => { - try { - await RunnerServer.Instance.run() - } catch (err) { - logger.error(err, 'Cannot run PeerTube runner as server mode') - process.exit(-1) - } - }) - -program.command('register') - .description('Register a new PeerTube instance to process runner jobs') - .requiredOption('--url ', 'PeerTube instance URL', parseUrl) - .requiredOption('--registration-token ', 'Runner registration token (can be found in PeerTube instance administration') - .requiredOption('--runner-name ', 'Runner name') - .option('--runner-description ', 'Runner description') - .action(async options => { - try { - await registerRunner(options) - } catch (err) { - console.error('Cannot register this PeerTube runner.') - console.error(err) - process.exit(-1) - } - }) - -program.command('unregister') - .description('Unregister the runner from PeerTube instance') - .requiredOption('--url ', 'PeerTube instance URL', parseUrl) - .requiredOption('--runner-name ', 'Runner name') - .action(async options => { - try { - await unregisterRunner(options) - } catch (err) { - console.error('Cannot unregister this PeerTube runner.') - console.error(err) - process.exit(-1) - } - }) - -program.command('list-registered') - .description('List registered PeerTube instances') - .action(async () => { - try { - await listRegistered() - } catch (err) { - console.error('Cannot list registered PeerTube instances.') - console.error(err) - process.exit(-1) - } - }) - -program.parse() - -// --------------------------------------------------------------------------- -// Private -// --------------------------------------------------------------------------- - -function parseUrl (url: string) { - if (url.startsWith('http://') !== true && url.startsWith('https://') !== true) { - throw new InvalidArgumentError('URL should start with a http:// or https://') - } - - return url -} diff --git a/packages/peertube-runner/register/index.ts b/packages/peertube-runner/register/index.ts deleted file mode 100644 index 3d4273ef8..000000000 --- a/packages/peertube-runner/register/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './register' diff --git a/packages/peertube-runner/register/register.ts b/packages/peertube-runner/register/register.ts deleted file mode 100644 index ca1bf0f5a..000000000 --- a/packages/peertube-runner/register/register.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { IPCClient } from '../shared/ipc' - -export async function registerRunner (options: { - url: string - registrationToken: string - runnerName: string - runnerDescription?: string -}) { - const client = new IPCClient() - await client.run() - - await client.askRegister(options) - - client.stop() -} - -export async function unregisterRunner (options: { - url: string - runnerName: string -}) { - const client = new IPCClient() - await client.run() - - await client.askUnregister(options) - - client.stop() -} - -export async function listRegistered () { - const client = new IPCClient() - await client.run() - - await client.askListRegistered() - - client.stop() -} diff --git a/packages/peertube-runner/server/index.ts b/packages/peertube-runner/server/index.ts deleted file mode 100644 index 371836515..000000000 --- a/packages/peertube-runner/server/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './server' diff --git a/packages/peertube-runner/server/process/index.ts b/packages/peertube-runner/server/process/index.ts deleted file mode 100644 index 6caedbdaf..000000000 --- a/packages/peertube-runner/server/process/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './shared' -export * from './process' diff --git a/packages/peertube-runner/server/process/process.ts b/packages/peertube-runner/server/process/process.ts deleted file mode 100644 index 1caafda8c..000000000 --- a/packages/peertube-runner/server/process/process.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { logger } from 'packages/peertube-runner/shared/logger' -import { - RunnerJobLiveRTMPHLSTranscodingPayload, - RunnerJobStudioTranscodingPayload, - RunnerJobVODAudioMergeTranscodingPayload, - RunnerJobVODHLSTranscodingPayload, - RunnerJobVODWebVideoTranscodingPayload -} from '@shared/models' -import { processAudioMergeTranscoding, processHLSTranscoding, ProcessOptions, processWebVideoTranscoding } from './shared' -import { ProcessLiveRTMPHLSTranscoding } from './shared/process-live' -import { processStudioTranscoding } from './shared/process-studio' - -export async function processJob (options: ProcessOptions) { - const { server, job } = options - - logger.info(`[${server.url}] Processing job of type ${job.type}: ${job.uuid}`, { payload: job.payload }) - - if (job.type === 'vod-audio-merge-transcoding') { - await processAudioMergeTranscoding(options as ProcessOptions) - } else if (job.type === 'vod-web-video-transcoding') { - await processWebVideoTranscoding(options as ProcessOptions) - } else if (job.type === 'vod-hls-transcoding') { - await processHLSTranscoding(options as ProcessOptions) - } else if (job.type === 'live-rtmp-hls-transcoding') { - await new ProcessLiveRTMPHLSTranscoding(options as ProcessOptions).process() - } else if (job.type === 'video-studio-transcoding') { - await processStudioTranscoding(options as ProcessOptions) - } else { - logger.error(`Unknown job ${job.type} to process`) - return - } - - logger.info(`[${server.url}] Finished processing job of type ${job.type}: ${job.uuid}`) -} diff --git a/packages/peertube-runner/server/process/shared/common.ts b/packages/peertube-runner/server/process/shared/common.ts deleted file mode 100644 index a9b37bbc4..000000000 --- a/packages/peertube-runner/server/process/shared/common.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { remove } from 'fs-extra' -import { ConfigManager, downloadFile, logger } from 'packages/peertube-runner/shared' -import { join } from 'path' -import { buildUUID } from '@shared/extra-utils' -import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@shared/ffmpeg' -import { RunnerJob, RunnerJobPayload } from '@shared/models' -import { PeerTubeServer } from '@shared/server-commands' -import { getTranscodingLogger } from './transcoding-logger' - -export type JobWithToken = RunnerJob & { jobToken: string } - -export type ProcessOptions = { - server: PeerTubeServer - job: JobWithToken - runnerToken: string -} - -export async function downloadInputFile (options: { - url: string - job: JobWithToken - runnerToken: string -}) { - const { url, job, runnerToken } = options - const destination = join(ConfigManager.Instance.getTranscodingDirectory(), buildUUID()) - - try { - await downloadFile({ url, jobToken: job.jobToken, runnerToken, destination }) - } catch (err) { - remove(destination) - .catch(err => logger.error({ err }, `Cannot remove ${destination}`)) - - throw err - } - - return destination -} - -export function scheduleTranscodingProgress (options: { - server: PeerTubeServer - runnerToken: string - job: JobWithToken - progressGetter: () => number -}) { - const { job, server, progressGetter, runnerToken } = options - - const updateInterval = ConfigManager.Instance.isTestInstance() - ? 500 - : 60000 - - const update = () => { - server.runnerJobs.update({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken, progress: progressGetter() }) - .catch(err => logger.error({ err }, 'Cannot send job progress')) - } - - const interval = setInterval(() => { - update() - }, updateInterval) - - update() - - return interval -} - -// --------------------------------------------------------------------------- - -export function buildFFmpegVOD (options: { - onJobProgress: (progress: number) => void -}) { - const { onJobProgress } = options - - return new FFmpegVOD({ - ...getCommonFFmpegOptions(), - - updateJobProgress: arg => { - const progress = arg < 0 || arg > 100 - ? undefined - : arg - - onJobProgress(progress) - } - }) -} - -export function buildFFmpegLive () { - return new FFmpegLive(getCommonFFmpegOptions()) -} - -export function buildFFmpegEdition () { - return new FFmpegEdition(getCommonFFmpegOptions()) -} - -function getCommonFFmpegOptions () { - const config = ConfigManager.Instance.getConfig() - - return { - niceness: config.ffmpeg.nice, - threads: config.ffmpeg.threads, - tmpDirectory: ConfigManager.Instance.getTranscodingDirectory(), - profile: 'default', - availableEncoders: { - available: getDefaultAvailableEncoders(), - encodersToTry: getDefaultEncodersToTry() - }, - logger: getTranscodingLogger() - } -} diff --git a/packages/peertube-runner/server/process/shared/index.ts b/packages/peertube-runner/server/process/shared/index.ts deleted file mode 100644 index 556c51365..000000000 --- a/packages/peertube-runner/server/process/shared/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './common' -export * from './process-vod' -export * from './transcoding-logger' diff --git a/packages/peertube-runner/server/process/shared/process-live.ts b/packages/peertube-runner/server/process/shared/process-live.ts deleted file mode 100644 index e1fc0e34e..000000000 --- a/packages/peertube-runner/server/process/shared/process-live.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { FSWatcher, watch } from 'chokidar' -import { FfmpegCommand } from 'fluent-ffmpeg' -import { ensureDir, remove } from 'fs-extra' -import { logger } from 'packages/peertube-runner/shared' -import { basename, join } from 'path' -import { wait } from '@shared/core-utils' -import { buildUUID } from '@shared/extra-utils' -import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '@shared/ffmpeg' -import { - LiveRTMPHLSTranscodingSuccess, - LiveRTMPHLSTranscodingUpdatePayload, - PeerTubeProblemDocument, - RunnerJobLiveRTMPHLSTranscodingPayload, - ServerErrorCode -} from '@shared/models' -import { ConfigManager } from '../../../shared/config-manager' -import { buildFFmpegLive, ProcessOptions } from './common' - -export class ProcessLiveRTMPHLSTranscoding { - - private readonly outputPath: string - private readonly fsWatchers: FSWatcher[] = [] - - // Playlist name -> chunks - private readonly pendingChunksPerPlaylist = new Map() - - private readonly playlistsCreated = new Set() - private allPlaylistsCreated = false - - private ffmpegCommand: FfmpegCommand - - private ended = false - private errored = false - - constructor (private readonly options: ProcessOptions) { - this.outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), buildUUID()) - - logger.debug(`Using ${this.outputPath} to process live rtmp hls transcoding job ${options.job.uuid}`) - } - - process () { - const job = this.options.job - const payload = job.payload - - return new Promise(async (res, rej) => { - try { - await ensureDir(this.outputPath) - - logger.info(`Probing ${payload.input.rtmpUrl}`) - const probe = await ffprobePromise(payload.input.rtmpUrl) - logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`) - - const hasAudio = await hasAudioStream(payload.input.rtmpUrl, probe) - const bitrate = await getVideoStreamBitrate(payload.input.rtmpUrl, probe) - const { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe) - - const m3u8Watcher = watch(this.outputPath + '/*.m3u8') - this.fsWatchers.push(m3u8Watcher) - - const tsWatcher = watch(this.outputPath + '/*.ts') - this.fsWatchers.push(tsWatcher) - - m3u8Watcher.on('change', p => { - logger.debug(`${p} m3u8 playlist changed`) - }) - - m3u8Watcher.on('add', p => { - this.playlistsCreated.add(p) - - if (this.playlistsCreated.size === this.options.job.payload.output.toTranscode.length + 1) { - this.allPlaylistsCreated = true - logger.info('All m3u8 playlists are created.') - } - }) - - tsWatcher.on('add', async p => { - try { - await this.sendPendingChunks() - } catch (err) { - this.onUpdateError({ err, rej, res }) - } - - const playlistName = this.getPlaylistIdFromTS(p) - - const pendingChunks = this.pendingChunksPerPlaylist.get(playlistName) || [] - pendingChunks.push(p) - - this.pendingChunksPerPlaylist.set(playlistName, pendingChunks) - }) - - tsWatcher.on('unlink', p => { - this.sendDeletedChunkUpdate(p) - .catch(err => this.onUpdateError({ err, rej, res })) - }) - - this.ffmpegCommand = await buildFFmpegLive().getLiveTranscodingCommand({ - inputUrl: payload.input.rtmpUrl, - - outPath: this.outputPath, - masterPlaylistName: 'master.m3u8', - - segmentListSize: payload.output.segmentListSize, - segmentDuration: payload.output.segmentDuration, - - toTranscode: payload.output.toTranscode, - - bitrate, - ratio, - - hasAudio - }) - - logger.info(`Running live transcoding for ${payload.input.rtmpUrl}`) - - this.ffmpegCommand.on('error', (err, stdout, stderr) => { - this.onFFmpegError({ err, stdout, stderr }) - - res() - }) - - this.ffmpegCommand.on('end', () => { - this.onFFmpegEnded() - .catch(err => logger.error({ err }, 'Error in FFmpeg end handler')) - - res() - }) - - this.ffmpegCommand.run() - } catch (err) { - rej(err) - } - }) - } - - // --------------------------------------------------------------------------- - - private onUpdateError (options: { - err: Error - res: () => void - rej: (reason?: any) => void - }) { - const { err, res, rej } = options - - if (this.errored) return - if (this.ended) return - - this.errored = true - - this.ffmpegCommand.kill('SIGINT') - - const type = ((err as any).res?.body as PeerTubeProblemDocument)?.code - if (type === ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE) { - logger.info({ err }, 'Stopping transcoding as the job is not in processing state anymore') - - res() - } else { - logger.error({ err }, 'Cannot send update after added/deleted chunk, stopping live transcoding') - - this.sendError(err) - .catch(subErr => logger.error({ err: subErr }, 'Cannot send error')) - - rej(err) - } - - this.cleanup() - } - - // --------------------------------------------------------------------------- - - private onFFmpegError (options: { - err: any - stdout: string - stderr: string - }) { - const { err, stdout, stderr } = options - - // Don't care that we killed the ffmpeg process - if (err?.message?.includes('Exiting normally')) return - if (this.errored) return - if (this.ended) return - - this.errored = true - - logger.error({ err, stdout, stderr }, 'FFmpeg transcoding error.') - - this.sendError(err) - .catch(subErr => logger.error({ err: subErr }, 'Cannot send error')) - - this.cleanup() - } - - private async sendError (err: Error) { - await this.options.server.runnerJobs.error({ - jobToken: this.options.job.jobToken, - jobUUID: this.options.job.uuid, - runnerToken: this.options.runnerToken, - message: err.message - }) - } - - // --------------------------------------------------------------------------- - - private async onFFmpegEnded () { - if (this.ended) return - - this.ended = true - logger.info('FFmpeg ended, sending success to server') - - // Wait last ffmpeg chunks generation - await wait(1500) - - this.sendSuccess() - .catch(err => logger.error({ err }, 'Cannot send success')) - - this.cleanup() - } - - private async sendSuccess () { - const successBody: LiveRTMPHLSTranscodingSuccess = {} - - await this.options.server.runnerJobs.success({ - jobToken: this.options.job.jobToken, - jobUUID: this.options.job.uuid, - runnerToken: this.options.runnerToken, - payload: successBody - }) - } - - // --------------------------------------------------------------------------- - - private sendDeletedChunkUpdate (deletedChunk: string): Promise { - if (this.ended) return Promise.resolve() - - logger.debug(`Sending removed live chunk ${deletedChunk} update`) - - const videoChunkFilename = basename(deletedChunk) - - let payload: LiveRTMPHLSTranscodingUpdatePayload = { - type: 'remove-chunk', - videoChunkFilename - } - - if (this.allPlaylistsCreated) { - const playlistName = this.getPlaylistName(videoChunkFilename) - - payload = { - ...payload, - masterPlaylistFile: join(this.outputPath, 'master.m3u8'), - resolutionPlaylistFilename: playlistName, - resolutionPlaylistFile: join(this.outputPath, playlistName) - } - } - - return this.updateWithRetry(payload) - } - - private async sendPendingChunks (): Promise { - if (this.ended) return Promise.resolve() - - const promises: Promise[] = [] - - for (const playlist of this.pendingChunksPerPlaylist.keys()) { - for (const chunk of this.pendingChunksPerPlaylist.get(playlist)) { - logger.debug(`Sending added live chunk ${chunk} update`) - - const videoChunkFilename = basename(chunk) - - let payload: LiveRTMPHLSTranscodingUpdatePayload = { - type: 'add-chunk', - videoChunkFilename, - videoChunkFile: chunk - } - - if (this.allPlaylistsCreated) { - const playlistName = this.getPlaylistName(videoChunkFilename) - - payload = { - ...payload, - masterPlaylistFile: join(this.outputPath, 'master.m3u8'), - resolutionPlaylistFilename: playlistName, - resolutionPlaylistFile: join(this.outputPath, playlistName) - } - } - - promises.push(this.updateWithRetry(payload)) - } - - this.pendingChunksPerPlaylist.set(playlist, []) - } - - await Promise.all(promises) - } - - private async updateWithRetry (payload: LiveRTMPHLSTranscodingUpdatePayload, currentTry = 1): Promise { - if (this.ended || this.errored) return - - try { - await this.options.server.runnerJobs.update({ - jobToken: this.options.job.jobToken, - jobUUID: this.options.job.uuid, - runnerToken: this.options.runnerToken, - payload - }) - } catch (err) { - if (currentTry >= 3) throw err - if ((err.res?.body as PeerTubeProblemDocument)?.code === ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE) throw err - - logger.warn({ err }, 'Will retry update after error') - await wait(250) - - return this.updateWithRetry(payload, currentTry + 1) - } - } - - private getPlaylistName (videoChunkFilename: string) { - return `${videoChunkFilename.split('-')[0]}.m3u8` - } - - private getPlaylistIdFromTS (segmentPath: string) { - const playlistIdMatcher = /^([\d+])-/ - - return basename(segmentPath).match(playlistIdMatcher)[1] - } - - // --------------------------------------------------------------------------- - - private cleanup () { - logger.debug(`Cleaning up job ${this.options.job.uuid}`) - - for (const fsWatcher of this.fsWatchers) { - fsWatcher.close() - .catch(err => logger.error({ err }, 'Cannot close watcher')) - } - - remove(this.outputPath) - .catch(err => logger.error({ err }, `Cannot remove ${this.outputPath}`)) - } -} diff --git a/packages/peertube-runner/server/process/shared/process-studio.ts b/packages/peertube-runner/server/process/shared/process-studio.ts deleted file mode 100644 index 7bb209e80..000000000 --- a/packages/peertube-runner/server/process/shared/process-studio.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { remove } from 'fs-extra' -import { logger } from 'packages/peertube-runner/shared' -import { join } from 'path' -import { pick } from '@shared/core-utils' -import { buildUUID } from '@shared/extra-utils' -import { - RunnerJobStudioTranscodingPayload, - VideoStudioTask, - VideoStudioTaskCutPayload, - VideoStudioTaskIntroPayload, - VideoStudioTaskOutroPayload, - VideoStudioTaskPayload, - VideoStudioTaskWatermarkPayload, - VideoStudioTranscodingSuccess -} from '@shared/models' -import { ConfigManager } from '../../../shared/config-manager' -import { buildFFmpegEdition, downloadInputFile, JobWithToken, ProcessOptions, scheduleTranscodingProgress } from './common' - -export async function processStudioTranscoding (options: ProcessOptions) { - const { server, job, runnerToken } = options - const payload = job.payload - - let inputPath: string - let outputPath: string - let tmpInputFilePath: string - - let tasksProgress = 0 - - const updateProgressInterval = scheduleTranscodingProgress({ - job, - server, - runnerToken, - progressGetter: () => tasksProgress - }) - - try { - logger.info(`Downloading input file ${payload.input.videoFileUrl} for job ${job.jobToken}`) - - inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) - tmpInputFilePath = inputPath - - logger.info(`Input file ${payload.input.videoFileUrl} downloaded for job ${job.jobToken}. Running studio transcoding tasks.`) - - for (const task of payload.tasks) { - const outputFilename = 'output-edition-' + buildUUID() + '.mp4' - outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename) - - await processTask({ - inputPath: tmpInputFilePath, - outputPath, - task, - job, - runnerToken - }) - - if (tmpInputFilePath) await remove(tmpInputFilePath) - - // For the next iteration - tmpInputFilePath = outputPath - - tasksProgress += Math.floor(100 / payload.tasks.length) - } - - const successBody: VideoStudioTranscodingSuccess = { - videoFile: outputPath - } - - await server.runnerJobs.success({ - jobToken: job.jobToken, - jobUUID: job.uuid, - runnerToken, - payload: successBody - }) - } finally { - if (tmpInputFilePath) await remove(tmpInputFilePath) - if (outputPath) await remove(outputPath) - if (updateProgressInterval) clearInterval(updateProgressInterval) - } -} - -// --------------------------------------------------------------------------- -// Private -// --------------------------------------------------------------------------- - -type TaskProcessorOptions = { - inputPath: string - outputPath: string - task: T - runnerToken: string - job: JobWithToken -} - -const taskProcessors: { [id in VideoStudioTask['name']]: (options: TaskProcessorOptions) => Promise } = { - 'add-intro': processAddIntroOutro, - 'add-outro': processAddIntroOutro, - 'cut': processCut, - 'add-watermark': processAddWatermark -} - -async function processTask (options: TaskProcessorOptions) { - const { task } = options - - const processor = taskProcessors[options.task.name] - if (!process) throw new Error('Unknown task ' + task.name) - - return processor(options) -} - -async function processAddIntroOutro (options: TaskProcessorOptions) { - const { inputPath, task, runnerToken, job } = options - - logger.debug('Adding intro/outro to ' + inputPath) - - const introOutroPath = await downloadInputFile({ url: task.options.file, runnerToken, job }) - - try { - await buildFFmpegEdition().addIntroOutro({ - ...pick(options, [ 'inputPath', 'outputPath' ]), - - introOutroPath, - type: task.name === 'add-intro' - ? 'intro' - : 'outro' - }) - } finally { - await remove(introOutroPath) - } -} - -function processCut (options: TaskProcessorOptions) { - const { inputPath, task } = options - - logger.debug(`Cutting ${inputPath}`) - - return buildFFmpegEdition().cutVideo({ - ...pick(options, [ 'inputPath', 'outputPath' ]), - - start: task.options.start, - end: task.options.end - }) -} - -async function processAddWatermark (options: TaskProcessorOptions) { - const { inputPath, task, runnerToken, job } = options - - logger.debug('Adding watermark to ' + inputPath) - - const watermarkPath = await downloadInputFile({ url: task.options.file, runnerToken, job }) - - try { - await buildFFmpegEdition().addWatermark({ - ...pick(options, [ 'inputPath', 'outputPath' ]), - - watermarkPath, - - videoFilters: { - watermarkSizeRatio: task.options.watermarkSizeRatio, - horitonzalMarginRatio: task.options.horitonzalMarginRatio, - verticalMarginRatio: task.options.verticalMarginRatio - } - }) - } finally { - await remove(watermarkPath) - } -} diff --git a/packages/peertube-runner/server/process/shared/process-vod.ts b/packages/peertube-runner/server/process/shared/process-vod.ts deleted file mode 100644 index f7c076b27..000000000 --- a/packages/peertube-runner/server/process/shared/process-vod.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { remove } from 'fs-extra' -import { logger } from 'packages/peertube-runner/shared' -import { join } from 'path' -import { buildUUID } from '@shared/extra-utils' -import { - RunnerJobVODAudioMergeTranscodingPayload, - RunnerJobVODHLSTranscodingPayload, - RunnerJobVODWebVideoTranscodingPayload, - VODAudioMergeTranscodingSuccess, - VODHLSTranscodingSuccess, - VODWebVideoTranscodingSuccess -} from '@shared/models' -import { ConfigManager } from '../../../shared/config-manager' -import { buildFFmpegVOD, downloadInputFile, ProcessOptions, scheduleTranscodingProgress } from './common' - -export async function processWebVideoTranscoding (options: ProcessOptions) { - const { server, job, runnerToken } = options - - const payload = job.payload - - let ffmpegProgress: number - let inputPath: string - - const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`) - - const updateProgressInterval = scheduleTranscodingProgress({ - job, - server, - runnerToken, - progressGetter: () => ffmpegProgress - }) - - try { - logger.info(`Downloading input file ${payload.input.videoFileUrl} for web video transcoding job ${job.jobToken}`) - - inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) - - logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running web video transcoding.`) - - const ffmpegVod = buildFFmpegVOD({ - onJobProgress: progress => { ffmpegProgress = progress } - }) - - await ffmpegVod.transcode({ - type: 'video', - - inputPath, - - outputPath, - - inputFileMutexReleaser: () => {}, - - resolution: payload.output.resolution, - fps: payload.output.fps - }) - - const successBody: VODWebVideoTranscodingSuccess = { - videoFile: outputPath - } - - await server.runnerJobs.success({ - jobToken: job.jobToken, - jobUUID: job.uuid, - runnerToken, - payload: successBody - }) - } finally { - if (inputPath) await remove(inputPath) - if (outputPath) await remove(outputPath) - if (updateProgressInterval) clearInterval(updateProgressInterval) - } -} - -export async function processHLSTranscoding (options: ProcessOptions) { - const { server, job, runnerToken } = options - const payload = job.payload - - let ffmpegProgress: number - let inputPath: string - - const uuid = buildUUID() - const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`) - const videoFilename = `${uuid}-${payload.output.resolution}-fragmented.mp4` - const videoPath = join(join(ConfigManager.Instance.getTranscodingDirectory(), videoFilename)) - - const updateProgressInterval = scheduleTranscodingProgress({ - job, - server, - runnerToken, - progressGetter: () => ffmpegProgress - }) - - try { - logger.info(`Downloading input file ${payload.input.videoFileUrl} for HLS transcoding job ${job.jobToken}`) - - inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) - - logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running HLS transcoding.`) - - const ffmpegVod = buildFFmpegVOD({ - onJobProgress: progress => { ffmpegProgress = progress } - }) - - await ffmpegVod.transcode({ - type: 'hls', - copyCodecs: false, - inputPath, - hlsPlaylist: { videoFilename }, - outputPath, - - inputFileMutexReleaser: () => {}, - - resolution: payload.output.resolution, - fps: payload.output.fps - }) - - const successBody: VODHLSTranscodingSuccess = { - resolutionPlaylistFile: outputPath, - videoFile: videoPath - } - - await server.runnerJobs.success({ - jobToken: job.jobToken, - jobUUID: job.uuid, - runnerToken, - payload: successBody - }) - } finally { - if (inputPath) await remove(inputPath) - if (outputPath) await remove(outputPath) - if (videoPath) await remove(videoPath) - if (updateProgressInterval) clearInterval(updateProgressInterval) - } -} - -export async function processAudioMergeTranscoding (options: ProcessOptions) { - const { server, job, runnerToken } = options - const payload = job.payload - - let ffmpegProgress: number - let audioPath: string - let inputPath: string - - const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`) - - const updateProgressInterval = scheduleTranscodingProgress({ - job, - server, - runnerToken, - progressGetter: () => ffmpegProgress - }) - - try { - logger.info( - `Downloading input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` + - `for audio merge transcoding job ${job.jobToken}` - ) - - audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job }) - inputPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job }) - - logger.info( - `Downloaded input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` + - `for job ${job.jobToken}. Running audio merge transcoding.` - ) - - const ffmpegVod = buildFFmpegVOD({ - onJobProgress: progress => { ffmpegProgress = progress } - }) - - await ffmpegVod.transcode({ - type: 'merge-audio', - - audioPath, - inputPath, - - outputPath, - - inputFileMutexReleaser: () => {}, - - resolution: payload.output.resolution, - fps: payload.output.fps - }) - - const successBody: VODAudioMergeTranscodingSuccess = { - videoFile: outputPath - } - - await server.runnerJobs.success({ - jobToken: job.jobToken, - jobUUID: job.uuid, - runnerToken, - payload: successBody - }) - } finally { - if (audioPath) await remove(audioPath) - if (inputPath) await remove(inputPath) - if (outputPath) await remove(outputPath) - if (updateProgressInterval) clearInterval(updateProgressInterval) - } -} diff --git a/packages/peertube-runner/server/process/shared/transcoding-logger.ts b/packages/peertube-runner/server/process/shared/transcoding-logger.ts deleted file mode 100644 index d0f928914..000000000 --- a/packages/peertube-runner/server/process/shared/transcoding-logger.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { logger } from 'packages/peertube-runner/shared/logger' - -export function getTranscodingLogger () { - return { - info: logger.info.bind(logger), - debug: logger.debug.bind(logger), - warn: logger.warn.bind(logger), - error: logger.error.bind(logger) - } -} diff --git a/packages/peertube-runner/server/server.ts b/packages/peertube-runner/server/server.ts deleted file mode 100644 index 5fa86fa1a..000000000 --- a/packages/peertube-runner/server/server.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { ensureDir, readdir, remove } from 'fs-extra' -import { join } from 'path' -import { io, Socket } from 'socket.io-client' -import { pick, shuffle, wait } from '@shared/core-utils' -import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models' -import { PeerTubeServer as PeerTubeServerCommand } from '@shared/server-commands' -import { ConfigManager } from '../shared' -import { IPCServer } from '../shared/ipc' -import { logger } from '../shared/logger' -import { JobWithToken, processJob } from './process' -import { isJobSupported } from './shared' - -type PeerTubeServer = PeerTubeServerCommand & { - runnerToken: string - runnerName: string - runnerDescription?: string -} - -export class RunnerServer { - private static instance: RunnerServer - - private servers: PeerTubeServer[] = [] - private processingJobs: { job: JobWithToken, server: PeerTubeServer }[] = [] - - private checkingAvailableJobs = false - - private cleaningUp = false - - private readonly sockets = new Map() - - private constructor () {} - - async run () { - logger.info('Running PeerTube runner in server mode') - - await ConfigManager.Instance.load() - - for (const registered of ConfigManager.Instance.getConfig().registeredInstances) { - const serverCommand = new PeerTubeServerCommand({ url: registered.url }) - - this.loadServer(Object.assign(serverCommand, registered)) - - logger.info(`Loading registered instance ${registered.url}`) - } - - // Run IPC - const ipcServer = new IPCServer() - try { - await ipcServer.run(this) - } catch (err) { - logger.error('Cannot start local socket for IPC communication', err) - process.exit(-1) - } - - // Cleanup on exit - for (const code of [ 'SIGINT', 'SIGUSR1', 'SIGUSR2', 'uncaughtException' ]) { - process.on(code, async (err, origin) => { - if (code === 'uncaughtException') { - logger.error({ err, origin }, 'uncaughtException') - } - - await this.onExit() - }) - } - - // Process jobs - await ensureDir(ConfigManager.Instance.getTranscodingDirectory()) - await this.cleanupTMP() - - logger.info(`Using ${ConfigManager.Instance.getTranscodingDirectory()} for transcoding directory`) - - await this.checkAvailableJobs() - } - - // --------------------------------------------------------------------------- - - async registerRunner (options: { - url: string - registrationToken: string - runnerName: string - runnerDescription?: string - }) { - const { url, registrationToken, runnerName, runnerDescription } = options - - logger.info(`Registering runner ${runnerName} on ${url}...`) - - const serverCommand = new PeerTubeServerCommand({ url }) - const { runnerToken } = await serverCommand.runners.register({ name: runnerName, description: runnerDescription, registrationToken }) - - const server: PeerTubeServer = Object.assign(serverCommand, { - runnerToken, - runnerName, - runnerDescription - }) - - this.loadServer(server) - await this.saveRegisteredInstancesInConf() - - logger.info(`Registered runner ${runnerName} on ${url}`) - - await this.checkAvailableJobs() - } - - private loadServer (server: PeerTubeServer) { - this.servers.push(server) - - const url = server.url + '/runners' - const socket = io(url, { - auth: { - runnerToken: server.runnerToken - }, - transports: [ 'websocket' ] - }) - - socket.on('connect_error', err => logger.warn({ err }, `Cannot connect to ${url} socket`)) - socket.on('connect', () => logger.info(`Connected to ${url} socket`)) - socket.on('available-jobs', () => this.checkAvailableJobs()) - - this.sockets.set(server, socket) - } - - async unregisterRunner (options: { - url: string - runnerName: string - }) { - const { url, runnerName } = options - - const server = this.servers.find(s => s.url === url && s.runnerName === runnerName) - if (!server) { - logger.error(`Unknown server ${url} - ${runnerName} to unregister`) - return - } - - logger.info(`Unregistering runner ${runnerName} on ${url}...`) - - try { - await server.runners.unregister({ runnerToken: server.runnerToken }) - } catch (err) { - logger.error({ err }, `Cannot unregister runner ${runnerName} on ${url}`) - } - - this.unloadServer(server) - await this.saveRegisteredInstancesInConf() - - logger.info(`Unregistered runner ${runnerName} on ${url}`) - } - - private unloadServer (server: PeerTubeServer) { - this.servers = this.servers.filter(s => s !== server) - - const socket = this.sockets.get(server) - socket.disconnect() - - this.sockets.delete(server) - } - - listRegistered () { - return { - servers: this.servers.map(s => { - return { - url: s.url, - runnerName: s.runnerName, - runnerDescription: s.runnerDescription - } - }) - } - } - - // --------------------------------------------------------------------------- - - private async checkAvailableJobs () { - if (this.checkingAvailableJobs) return - - this.checkingAvailableJobs = true - - let hadAvailableJob = false - - for (const server of shuffle([ ...this.servers ])) { - try { - logger.info('Checking available jobs on ' + server.url) - - const job = await this.requestJob(server) - if (!job) continue - - hadAvailableJob = true - - await this.tryToExecuteJobAsync(server, job) - } catch (err) { - const code = (err.res?.body as PeerTubeProblemDocument)?.code - - if (code === ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE) { - logger.debug({ err }, 'Runner job is not in processing state anymore, retry later') - return - } - - if (code === ServerErrorCode.UNKNOWN_RUNNER_TOKEN) { - logger.error({ err }, `Unregistering ${server.url} as the runner token ${server.runnerToken} is invalid`) - - await this.unregisterRunner({ url: server.url, runnerName: server.runnerName }) - return - } - - logger.error({ err }, `Cannot request/accept job on ${server.url} for runner ${server.runnerName}`) - } - } - - this.checkingAvailableJobs = false - - if (hadAvailableJob && this.canProcessMoreJobs()) { - await wait(2500) - - this.checkAvailableJobs() - .catch(err => logger.error({ err }, 'Cannot check more available jobs')) - } - } - - private async requestJob (server: PeerTubeServer) { - logger.debug(`Requesting jobs on ${server.url} for runner ${server.runnerName}`) - - const { availableJobs } = await server.runnerJobs.request({ runnerToken: server.runnerToken }) - - const filtered = availableJobs.filter(j => isJobSupported(j)) - - if (filtered.length === 0) { - logger.debug(`No job available on ${server.url} for runner ${server.runnerName}`) - return undefined - } - - return filtered[0] - } - - private async tryToExecuteJobAsync (server: PeerTubeServer, jobToAccept: { uuid: string }) { - if (!this.canProcessMoreJobs()) return - - const { job } = await server.runnerJobs.accept({ runnerToken: server.runnerToken, jobUUID: jobToAccept.uuid }) - - const processingJob = { job, server } - this.processingJobs.push(processingJob) - - processJob({ server, job, runnerToken: server.runnerToken }) - .catch(err => { - logger.error({ err }, 'Cannot process job') - - server.runnerJobs.error({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken: server.runnerToken, message: err.message }) - .catch(err2 => logger.error({ err: err2 }, 'Cannot abort job after error')) - }) - .finally(() => { - this.processingJobs = this.processingJobs.filter(p => p !== processingJob) - - return this.checkAvailableJobs() - }) - } - - // --------------------------------------------------------------------------- - - private saveRegisteredInstancesInConf () { - const data = this.servers.map(s => { - return pick(s, [ 'url', 'runnerToken', 'runnerName', 'runnerDescription' ]) - }) - - return ConfigManager.Instance.setRegisteredInstances(data) - } - - private canProcessMoreJobs () { - return this.processingJobs.length < ConfigManager.Instance.getConfig().jobs.concurrency - } - - // --------------------------------------------------------------------------- - - private async cleanupTMP () { - const files = await readdir(ConfigManager.Instance.getTranscodingDirectory()) - - for (const file of files) { - await remove(join(ConfigManager.Instance.getTranscodingDirectory(), file)) - } - } - - private async onExit () { - if (this.cleaningUp) return - this.cleaningUp = true - - logger.info('Cleaning up after program exit') - - try { - for (const { server, job } of this.processingJobs) { - await server.runnerJobs.abort({ - jobToken: job.jobToken, - jobUUID: job.uuid, - reason: 'Runner stopped', - runnerToken: server.runnerToken - }) - } - - await this.cleanupTMP() - } catch (err) { - logger.error(err) - process.exit(-1) - } - - process.exit() - } - - static get Instance () { - return this.instance || (this.instance = new this()) - } -} diff --git a/packages/peertube-runner/server/shared/index.ts b/packages/peertube-runner/server/shared/index.ts deleted file mode 100644 index 5c86bafc0..000000000 --- a/packages/peertube-runner/server/shared/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './supported-job' diff --git a/packages/peertube-runner/server/shared/supported-job.ts b/packages/peertube-runner/server/shared/supported-job.ts deleted file mode 100644 index 1137d8206..000000000 --- a/packages/peertube-runner/server/shared/supported-job.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - RunnerJobLiveRTMPHLSTranscodingPayload, - RunnerJobPayload, - RunnerJobType, - RunnerJobStudioTranscodingPayload, - RunnerJobVODAudioMergeTranscodingPayload, - RunnerJobVODHLSTranscodingPayload, - RunnerJobVODWebVideoTranscodingPayload, - VideoStudioTaskPayload -} from '@shared/models' - -const supportedMatrix = { - 'vod-web-video-transcoding': (_payload: RunnerJobVODWebVideoTranscodingPayload) => { - return true - }, - 'vod-hls-transcoding': (_payload: RunnerJobVODHLSTranscodingPayload) => { - return true - }, - 'vod-audio-merge-transcoding': (_payload: RunnerJobVODAudioMergeTranscodingPayload) => { - return true - }, - 'live-rtmp-hls-transcoding': (_payload: RunnerJobLiveRTMPHLSTranscodingPayload) => { - return true - }, - 'video-studio-transcoding': (payload: RunnerJobStudioTranscodingPayload) => { - const tasks = payload?.tasks - const supported = new Set([ 'add-intro', 'add-outro', 'add-watermark', 'cut' ]) - - if (!Array.isArray(tasks)) return false - - return tasks.every(t => t && supported.has(t.name)) - } -} - -export function isJobSupported (job: { - type: RunnerJobType - payload: RunnerJobPayload -}) { - const fn = supportedMatrix[job.type] - if (!fn) return false - - return fn(job.payload as any) -} diff --git a/packages/peertube-runner/shared/config-manager.ts b/packages/peertube-runner/shared/config-manager.ts deleted file mode 100644 index 548eeab85..000000000 --- a/packages/peertube-runner/shared/config-manager.ts +++ /dev/null @@ -1,139 +0,0 @@ -import envPaths from 'env-paths' -import { ensureDir, pathExists, readFile, remove, writeFile } from 'fs-extra' -import { merge } from 'lodash' -import { logger } from 'packages/peertube-runner/shared/logger' -import { dirname, join } from 'path' -import { parse, stringify } from '@iarna/toml' - -const paths = envPaths('peertube-runner') - -type Config = { - jobs: { - concurrency: number - } - - ffmpeg: { - threads: number - nice: number - } - - registeredInstances: { - url: string - runnerToken: string - runnerName: string - runnerDescription?: string - }[] -} - -export class ConfigManager { - private static instance: ConfigManager - - private config: Config = { - jobs: { - concurrency: 2 - }, - ffmpeg: { - threads: 2, - nice: 20 - }, - registeredInstances: [] - } - - private id: string - private configFilePath: string - - private constructor () {} - - init (id: string) { - this.id = id - this.configFilePath = join(this.getConfigDir(), 'config.toml') - } - - async load () { - logger.info(`Using ${this.configFilePath} as configuration file`) - - if (this.isTestInstance()) { - logger.info('Removing configuration file as we are using the "test" id') - await remove(this.configFilePath) - } - - await ensureDir(dirname(this.configFilePath)) - - if (!await pathExists(this.configFilePath)) { - await this.save() - } - - const file = await readFile(this.configFilePath, 'utf-8') - - this.config = merge(this.config, parse(file)) - } - - save () { - return writeFile(this.configFilePath, stringify(this.config)) - } - - // --------------------------------------------------------------------------- - - async setRegisteredInstances (registeredInstances: { - url: string - runnerToken: string - runnerName: string - runnerDescription?: string - }[]) { - this.config.registeredInstances = registeredInstances - - await this.save() - } - - // --------------------------------------------------------------------------- - - getConfig () { - return this.deepFreeze(this.config) - } - - // --------------------------------------------------------------------------- - - getTranscodingDirectory () { - return join(paths.cache, this.id, 'transcoding') - } - - getSocketDirectory () { - return join(paths.data, this.id) - } - - getSocketPath () { - return join(this.getSocketDirectory(), 'peertube-runner.sock') - } - - getConfigDir () { - return join(paths.config, this.id) - } - - // --------------------------------------------------------------------------- - - isTestInstance () { - return typeof this.id === 'string' && this.id.match(/^test-\d$/) - } - - // --------------------------------------------------------------------------- - - // Thanks: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze - private deepFreeze (object: T) { - const propNames = Reflect.ownKeys(object) - - // Freeze properties before freezing self - for (const name of propNames) { - const value = object[name] - - if ((value && typeof value === 'object') || typeof value === 'function') { - this.deepFreeze(value) - } - } - - return Object.freeze({ ...object }) - } - - static get Instance () { - return this.instance || (this.instance = new this()) - } -} diff --git a/packages/peertube-runner/shared/http.ts b/packages/peertube-runner/shared/http.ts deleted file mode 100644 index df64dc168..000000000 --- a/packages/peertube-runner/shared/http.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { createWriteStream, remove } from 'fs-extra' -import { request as requestHTTP } from 'http' -import { request as requestHTTPS, RequestOptions } from 'https' -import { logger } from './logger' - -export function downloadFile (options: { - url: string - destination: string - runnerToken: string - jobToken: string -}) { - const { url, destination, runnerToken, jobToken } = options - - logger.debug(`Downloading file ${url}`) - - return new Promise((res, rej) => { - const parsed = new URL(url) - - const body = JSON.stringify({ - runnerToken, - jobToken - }) - - const getOptions: RequestOptions = { - method: 'POST', - hostname: parsed.hostname, - port: parsed.port, - path: parsed.pathname, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body, 'utf-8') - } - } - - const request = getRequest(url)(getOptions, response => { - const code = response.statusCode ?? 0 - - if (code >= 400) { - return rej(new Error(response.statusMessage)) - } - - const file = createWriteStream(destination) - file.on('finish', () => res()) - - response.pipe(file) - }) - - request.on('error', err => { - remove(destination) - .catch(err => logger.error(err)) - - return rej(err) - }) - - request.write(body) - request.end() - }) -} - -// --------------------------------------------------------------------------- - -function getRequest (url: string) { - if (url.startsWith('https://')) return requestHTTPS - - return requestHTTP -} diff --git a/packages/peertube-runner/shared/index.ts b/packages/peertube-runner/shared/index.ts deleted file mode 100644 index d0b5a2e3e..000000000 --- a/packages/peertube-runner/shared/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './config-manager' -export * from './http' -export * from './logger' diff --git a/packages/peertube-runner/shared/ipc/index.ts b/packages/peertube-runner/shared/ipc/index.ts deleted file mode 100644 index ad4590281..000000000 --- a/packages/peertube-runner/shared/ipc/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './ipc-client' -export * from './ipc-server' diff --git a/packages/peertube-runner/shared/ipc/ipc-client.ts b/packages/peertube-runner/shared/ipc/ipc-client.ts deleted file mode 100644 index f8e72f97f..000000000 --- a/packages/peertube-runner/shared/ipc/ipc-client.ts +++ /dev/null @@ -1,88 +0,0 @@ -import CliTable3 from 'cli-table3' -import { ensureDir } from 'fs-extra' -import { Client as NetIPC } from 'net-ipc' -import { ConfigManager } from '../config-manager' -import { IPCReponse, IPCReponseData, IPCRequest } from './shared' - -export class IPCClient { - private netIPC: NetIPC - - async run () { - await ensureDir(ConfigManager.Instance.getSocketDirectory()) - - const socketPath = ConfigManager.Instance.getSocketPath() - - this.netIPC = new NetIPC({ path: socketPath }) - - try { - await this.netIPC.connect() - } catch (err) { - if (err.code === 'ECONNREFUSED') { - throw new Error( - 'This runner is not currently running in server mode on this system. ' + - 'Please run it using the `server` command first (in another terminal for example) and then retry your command.' - ) - } - - throw err - } - } - - async askRegister (options: { - url: string - registrationToken: string - runnerName: string - runnerDescription?: string - }) { - const req: IPCRequest = { - type: 'register', - ...options - } - - const { success, error } = await this.netIPC.request(req) as IPCReponse - - if (success) console.log('PeerTube instance registered') - else console.error('Could not register PeerTube instance on runner server side', error) - } - - async askUnregister (options: { - url: string - runnerName: string - }) { - const req: IPCRequest = { - type: 'unregister', - ...options - } - - const { success, error } = await this.netIPC.request(req) as IPCReponse - - if (success) console.log('PeerTube instance unregistered') - else console.error('Could not unregister PeerTube instance on runner server side', error) - } - - async askListRegistered () { - const req: IPCRequest = { - type: 'list-registered' - } - - const { success, error, data } = await this.netIPC.request(req) as IPCReponse - if (!success) { - console.error('Could not list registered PeerTube instances', error) - return - } - - const table = new CliTable3({ - head: [ 'instance', 'runner name', 'runner description' ] - }) - - for (const server of data.servers) { - table.push([ server.url, server.runnerName, server.runnerDescription ]) - } - - console.log(table.toString()) - } - - stop () { - this.netIPC.destroy() - } -} diff --git a/packages/peertube-runner/shared/ipc/ipc-server.ts b/packages/peertube-runner/shared/ipc/ipc-server.ts deleted file mode 100644 index 4b67d01ae..000000000 --- a/packages/peertube-runner/shared/ipc/ipc-server.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ensureDir } from 'fs-extra' -import { Server as NetIPC } from 'net-ipc' -import { pick } from '@shared/core-utils' -import { RunnerServer } from '../../server' -import { ConfigManager } from '../config-manager' -import { logger } from '../logger' -import { IPCReponse, IPCReponseData, IPCRequest } from './shared' - -export class IPCServer { - private netIPC: NetIPC - private runnerServer: RunnerServer - - async run (runnerServer: RunnerServer) { - this.runnerServer = runnerServer - - await ensureDir(ConfigManager.Instance.getSocketDirectory()) - - const socketPath = ConfigManager.Instance.getSocketPath() - this.netIPC = new NetIPC({ path: socketPath }) - await this.netIPC.start() - - logger.info(`IPC socket created on ${socketPath}`) - - this.netIPC.on('request', async (req: IPCRequest, res) => { - try { - const data = await this.process(req) - - this.sendReponse(res, { success: true, data }) - } catch (err) { - logger.error('Cannot execute RPC call', err) - this.sendReponse(res, { success: false, error: err.message }) - } - }) - } - - private async process (req: IPCRequest) { - switch (req.type) { - case 'register': - await this.runnerServer.registerRunner(pick(req, [ 'url', 'registrationToken', 'runnerName', 'runnerDescription' ])) - return undefined - - case 'unregister': - await this.runnerServer.unregisterRunner(pick(req, [ 'url', 'runnerName' ])) - return undefined - - case 'list-registered': - return Promise.resolve(this.runnerServer.listRegistered()) - - default: - throw new Error('Unknown RPC call ' + (req as any).type) - } - } - - private sendReponse ( - response: (data: any) => Promise, - body: IPCReponse - ) { - response(body) - .catch(err => logger.error('Cannot send response after IPC request', err)) - } -} diff --git a/packages/peertube-runner/shared/ipc/shared/index.ts b/packages/peertube-runner/shared/ipc/shared/index.ts deleted file mode 100644 index deaaa152e..000000000 --- a/packages/peertube-runner/shared/ipc/shared/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './ipc-request.model' -export * from './ipc-response.model' diff --git a/packages/peertube-runner/shared/ipc/shared/ipc-request.model.ts b/packages/peertube-runner/shared/ipc/shared/ipc-request.model.ts deleted file mode 100644 index 352808c74..000000000 --- a/packages/peertube-runner/shared/ipc/shared/ipc-request.model.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type IPCRequest = - IPCRequestRegister | - IPCRequestUnregister | - IPCRequestListRegistered - -export type IPCRequestRegister = { - type: 'register' - url: string - registrationToken: string - runnerName: string - runnerDescription?: string -} - -export type IPCRequestUnregister = { type: 'unregister', url: string, runnerName: string } -export type IPCRequestListRegistered = { type: 'list-registered' } diff --git a/packages/peertube-runner/shared/ipc/shared/ipc-response.model.ts b/packages/peertube-runner/shared/ipc/shared/ipc-response.model.ts deleted file mode 100644 index 689d6e09a..000000000 --- a/packages/peertube-runner/shared/ipc/shared/ipc-response.model.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type IPCReponse = { - success: boolean - error?: string - data?: T -} - -export type IPCReponseData = - // list registered - { - servers: { - runnerName: string - runnerDescription: string - url: string - }[] - } diff --git a/packages/peertube-runner/shared/logger.ts b/packages/peertube-runner/shared/logger.ts deleted file mode 100644 index bf0f41828..000000000 --- a/packages/peertube-runner/shared/logger.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { pino } from 'pino' -import pretty from 'pino-pretty' - -const logger = pino(pretty({ - colorize: true -})) - -logger.level = 'info' - -export { - logger -} diff --git a/packages/peertube-runner/tsconfig.json b/packages/peertube-runner/tsconfig.json deleted file mode 100644 index b6c62bc34..000000000 --- a/packages/peertube-runner/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist" - }, - "references": [ - { "path": "../../shared" } - ] -} diff --git a/packages/peertube-runner/yarn.lock b/packages/peertube-runner/yarn.lock deleted file mode 100644 index adb5aa118..000000000 --- a/packages/peertube-runner/yarn.lock +++ /dev/null @@ -1,528 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@commander-js/extra-typings@^10.0.3": - version "10.0.3" - resolved "https://registry.yarnpkg.com/@commander-js/extra-typings/-/extra-typings-10.0.3.tgz#8b6c64897231ed9c00461db82018b5131b653aae" - integrity sha512-OIw28QV/GlP8k0B5CJTRsl8IyNvd0R8C8rfo54Yz9P388vCNDgdNrFlKxZTGqps+5j6lSw3Ss9JTQwcur1w1oA== - -"@esbuild/android-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.15.tgz#893ad71f3920ccb919e1757c387756a9bca2ef42" - integrity sha512-0kOB6Y7Br3KDVgHeg8PRcvfLkq+AccreK///B4Z6fNZGr/tNHX0z2VywCc7PTeWp+bPvjA5WMvNXltHw5QjAIA== - -"@esbuild/android-arm@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.15.tgz#143e0d4e4c08c786ea410b9a7739779a9a1315d8" - integrity sha512-sRSOVlLawAktpMvDyJIkdLI/c/kdRTOqo8t6ImVxg8yT7LQDUYV5Rp2FKeEosLr6ZCja9UjYAzyRSxGteSJPYg== - -"@esbuild/android-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.15.tgz#d2d12a7676b2589864281b2274355200916540bc" - integrity sha512-MzDqnNajQZ63YkaUWVl9uuhcWyEyh69HGpMIrf+acR4otMkfLJ4sUCxqwbCyPGicE9dVlrysI3lMcDBjGiBBcQ== - -"@esbuild/darwin-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.15.tgz#2e88e79f1d327a2a7d9d06397e5232eb0a473d61" - integrity sha512-7siLjBc88Z4+6qkMDxPT2juf2e8SJxmsbNVKFY2ifWCDT72v5YJz9arlvBw5oB4W/e61H1+HDB/jnu8nNg0rLA== - -"@esbuild/darwin-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz#9384e64c0be91388c57be6d3a5eaf1c32a99c91d" - integrity sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg== - -"@esbuild/freebsd-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.15.tgz#2ad5a35bc52ebd9ca6b845dbc59ba39647a93c1a" - integrity sha512-Xk9xMDjBVG6CfgoqlVczHAdJnCs0/oeFOspFap5NkYAmRCT2qTn1vJWA2f419iMtsHSLm+O8B6SLV/HlY5cYKg== - -"@esbuild/freebsd-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.15.tgz#b513a48446f96c75fda5bef470e64d342d4379cd" - integrity sha512-3TWAnnEOdclvb2pnfsTWtdwthPfOz7qAfcwDLcfZyGJwm1SRZIMOeB5FODVhnM93mFSPsHB9b/PmxNNbSnd0RQ== - -"@esbuild/linux-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.15.tgz#9697b168175bfd41fa9cc4a72dd0d48f24715f31" - integrity sha512-T0MVnYw9KT6b83/SqyznTs/3Jg2ODWrZfNccg11XjDehIved2oQfrX/wVuev9N936BpMRaTR9I1J0tdGgUgpJA== - -"@esbuild/linux-arm@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.15.tgz#5b22062c54f48cd92fab9ffd993732a52db70cd3" - integrity sha512-MLTgiXWEMAMr8nmS9Gigx43zPRmEfeBfGCwxFQEMgJ5MC53QKajaclW6XDPjwJvhbebv+RzK05TQjvH3/aM4Xw== - -"@esbuild/linux-ia32@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.15.tgz#eb28a13f9b60b5189fcc9e98e1024f6b657ba54c" - integrity sha512-wp02sHs015T23zsQtU4Cj57WiteiuASHlD7rXjKUyAGYzlOKDAjqK6bk5dMi2QEl/KVOcsjwL36kD+WW7vJt8Q== - -"@esbuild/linux-loong64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.15.tgz#32454bdfe144cf74b77895a8ad21a15cb81cfbe5" - integrity sha512-k7FsUJjGGSxwnBmMh8d7IbObWu+sF/qbwc+xKZkBe/lTAF16RqxRCnNHA7QTd3oS2AfGBAnHlXL67shV5bBThQ== - -"@esbuild/linux-mips64el@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.15.tgz#af12bde0d775a318fad90eb13a0455229a63987c" - integrity sha512-ZLWk6czDdog+Q9kE/Jfbilu24vEe/iW/Sj2d8EVsmiixQ1rM2RKH2n36qfxK4e8tVcaXkvuV3mU5zTZviE+NVQ== - -"@esbuild/linux-ppc64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.15.tgz#34c5ed145b2dfc493d3e652abac8bd3baa3865a5" - integrity sha512-mY6dPkIRAiFHRsGfOYZC8Q9rmr8vOBZBme0/j15zFUKM99d4ILY4WpOC7i/LqoY+RE7KaMaSfvY8CqjJtuO4xg== - -"@esbuild/linux-riscv64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.15.tgz#87bd515e837f2eb004b45f9e6a94dc5b93f22b92" - integrity sha512-EcyUtxffdDtWjjwIH8sKzpDRLcVtqANooMNASO59y+xmqqRYBBM7xVLQhqF7nksIbm2yHABptoioS9RAbVMWVA== - -"@esbuild/linux-s390x@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.15.tgz#20bf7947197f199ddac2ec412029a414ceae3aa3" - integrity sha512-BuS6Jx/ezxFuHxgsfvz7T4g4YlVrmCmg7UAwboeyNNg0OzNzKsIZXpr3Sb/ZREDXWgt48RO4UQRDBxJN3B9Rbg== - -"@esbuild/linux-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.15.tgz#31b93f9c94c195e852c20cd3d1914a68aa619124" - integrity sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg== - -"@esbuild/netbsd-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.15.tgz#8da299b3ac6875836ca8cdc1925826498069ac65" - integrity sha512-R6fKjtUysYGym6uXf6qyNephVUQAGtf3n2RCsOST/neIwPqRWcnc3ogcielOd6pT+J0RDR1RGcy0ZY7d3uHVLA== - -"@esbuild/openbsd-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.15.tgz#04a1ec3d4e919714dba68dcf09eeb1228ad0d20c" - integrity sha512-mVD4PGc26b8PI60QaPUltYKeSX0wxuy0AltC+WCTFwvKCq2+OgLP4+fFd+hZXzO2xW1HPKcytZBdjqL6FQFa7w== - -"@esbuild/sunos-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.15.tgz#6694ebe4e16e5cd7dab6505ff7c28f9c1c695ce5" - integrity sha512-U6tYPovOkw3459t2CBwGcFYfFRjivcJJc1WC8Q3funIwX8x4fP+R6xL/QuTPNGOblbq/EUDxj9GU+dWKX0oWlQ== - -"@esbuild/win32-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.15.tgz#1f95b2564193c8d1fee8f8129a0609728171d500" - integrity sha512-W+Z5F++wgKAleDABemiyXVnzXgvRFs+GVKThSI+mGgleLWluv0D7Diz4oQpgdpNzh4i2nNDzQtWbjJiqutRp6Q== - -"@esbuild/win32-ia32@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.15.tgz#c362b88b3df21916ed7bcf75c6d09c6bf3ae354a" - integrity sha512-Muz/+uGgheShKGqSVS1KsHtCyEzcdOn/W/Xbh6H91Etm+wiIfwZaBn1W58MeGtfI8WA961YMHFYTthBdQs4t+w== - -"@esbuild/win32-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.15.tgz#c2e737f3a201ebff8e2ac2b8e9f246b397ad19b8" - integrity sha512-DjDa9ywLUUmjhV2Y9wUTIF+1XsmuFGvZoCmOWkli1XcNAh5t25cc7fgsCx4Zi/Uurep3TTLyDiKATgGEg61pkA== - -"@iarna/toml@^2.2.5": - version "2.2.5" - resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c" - integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg== - -"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz#44d752c1a2dc113f15f781b7cc4f53a307e3fa38" - integrity sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ== - -"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz#f954f34355712212a8e06c465bc06c40852c6bb3" - integrity sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw== - -"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz#45c63037f045c2b15c44f80f0393fa24f9655367" - integrity sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg== - -"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz#35707efeafe6d22b3f373caf9e8775e8920d1399" - integrity sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA== - -"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz#091b1218b66c341f532611477ef89e83f25fae4f" - integrity sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA== - -"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz#0f164b726869f71da3c594171df5ebc1c4b0a407" - integrity sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ== - -abort-controller@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" - integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== - dependencies: - event-target-shim "^5.0.0" - -atomic-sleep@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" - integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - -brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== - dependencies: - balanced-match "^1.0.0" - -buffer@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" - integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.2.1" - -colorette@^2.0.7: - version "2.0.19" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" - integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== - -dateformat@^4.6.3: - version "4.6.3" - resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5" - integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== - -end-of-stream@^1.1.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - -env-paths@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-3.0.0.tgz#2f1e89c2f6dbd3408e1b1711dd82d62e317f58da" - integrity sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A== - -esbuild@^0.17.15: - version "0.17.15" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.15.tgz#209ebc87cb671ffb79574db93494b10ffaf43cbc" - integrity sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw== - optionalDependencies: - "@esbuild/android-arm" "0.17.15" - "@esbuild/android-arm64" "0.17.15" - "@esbuild/android-x64" "0.17.15" - "@esbuild/darwin-arm64" "0.17.15" - "@esbuild/darwin-x64" "0.17.15" - "@esbuild/freebsd-arm64" "0.17.15" - "@esbuild/freebsd-x64" "0.17.15" - "@esbuild/linux-arm" "0.17.15" - "@esbuild/linux-arm64" "0.17.15" - "@esbuild/linux-ia32" "0.17.15" - "@esbuild/linux-loong64" "0.17.15" - "@esbuild/linux-mips64el" "0.17.15" - "@esbuild/linux-ppc64" "0.17.15" - "@esbuild/linux-riscv64" "0.17.15" - "@esbuild/linux-s390x" "0.17.15" - "@esbuild/linux-x64" "0.17.15" - "@esbuild/netbsd-x64" "0.17.15" - "@esbuild/openbsd-x64" "0.17.15" - "@esbuild/sunos-x64" "0.17.15" - "@esbuild/win32-arm64" "0.17.15" - "@esbuild/win32-ia32" "0.17.15" - "@esbuild/win32-x64" "0.17.15" - -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - -events@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" - integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== - -fast-copy@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-3.0.1.tgz#9e89ef498b8c04c1cd76b33b8e14271658a732aa" - integrity sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA== - -fast-redact@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.1.2.tgz#d58e69e9084ce9fa4c1a6fa98a3e1ecf5d7839aa" - integrity sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw== - -fast-safe-stringify@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" - integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== - -fast-zlib@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fast-zlib/-/fast-zlib-2.0.1.tgz#be624f592fc80ad8019ee2025d16a367a4e9b024" - integrity sha512-DCoYgNagM2Bt1VIpXpdGnRx4LzqJeYG0oh6Nf/7cWo6elTXkFGMw9CrRCYYUIapYNrozYMoyDRflx9mgT3Awyw== - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -glob@^8.0.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" - integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^5.0.1" - once "^1.3.0" - -help-me@^4.0.1: - version "4.2.0" - resolved "https://registry.yarnpkg.com/help-me/-/help-me-4.2.0.tgz#50712bfd799ff1854ae1d312c36eafcea85b0563" - integrity sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA== - dependencies: - glob "^8.0.0" - readable-stream "^3.6.0" - -ieee754@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -joycon@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" - integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== - -minimatch@^5.0.1: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== - dependencies: - brace-expansion "^2.0.1" - -minimist@^1.2.6: - version "1.2.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" - integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - -msgpackr-extract@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-3.0.2.tgz#e05ec1bb4453ddf020551bcd5daaf0092a2c279d" - integrity sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A== - dependencies: - node-gyp-build-optional-packages "5.0.7" - optionalDependencies: - "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.2" - "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.2" - "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.2" - "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.2" - "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.2" - "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.2" - -msgpackr@^1.3.2: - version "1.8.5" - resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.8.5.tgz#8cadfb935357680648f33699d0e833c9179dbfeb" - integrity sha512-mpPs3qqTug6ahbblkThoUY2DQdNXcm4IapwOS3Vm/87vmpzLVelvp9h3It1y9l1VPpiFLV11vfOXnmeEwiIXwg== - optionalDependencies: - msgpackr-extract "^3.0.1" - -net-ipc@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/net-ipc/-/net-ipc-2.0.1.tgz#1da79ca16f1624f2ed1099a124cb065912c595a5" - integrity sha512-4HLjZ/Xorj4kxA7WUajF2EAXlS+OR+XliDLkqQA53Wm7eIr/hWLjdXt4zzB6q4Ii8BB+HbuRbM9yLov3+ttRUw== - optionalDependencies: - fast-zlib "^2.0.1" - msgpackr "^1.3.2" - -node-gyp-build-optional-packages@5.0.7: - version "5.0.7" - resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz#5d2632bbde0ab2f6e22f1bbac2199b07244ae0b3" - integrity sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w== - -on-exit-leak-free@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz#5c703c968f7e7f851885f6459bf8a8a57edc9cc4" - integrity sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w== - -once@^1.3.0, once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -pino-abstract-transport@^1.0.0, pino-abstract-transport@v1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz#cc0d6955fffcadb91b7b49ef220a6cc111d48bb3" - integrity sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA== - dependencies: - readable-stream "^4.0.0" - split2 "^4.0.0" - -pino-pretty@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-10.0.0.tgz#fd2f307ee897289f63d09b0b804ac2ecc9a18516" - integrity sha512-zKFjYXBzLaLTEAN1ayKpHXtL5UeRQC7R3lvhKe7fWs7hIVEjKGG/qIXwQt9HmeUp71ogUd/YcW+LmMwRp4KT6Q== - dependencies: - colorette "^2.0.7" - dateformat "^4.6.3" - fast-copy "^3.0.0" - fast-safe-stringify "^2.1.1" - help-me "^4.0.1" - joycon "^3.1.1" - minimist "^1.2.6" - on-exit-leak-free "^2.1.0" - pino-abstract-transport "^1.0.0" - pump "^3.0.0" - readable-stream "^4.0.0" - secure-json-parse "^2.4.0" - sonic-boom "^3.0.0" - strip-json-comments "^3.1.1" - -pino-std-serializers@^6.0.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-6.2.0.tgz#169048c0df3f61352fce56aeb7fb962f1b66ab43" - integrity sha512-IWgSzUL8X1w4BIWTwErRgtV8PyOGOOi60uqv0oKuS/fOA8Nco/OeI6lBuc4dyP8MMfdFwyHqTMcBIA7nDiqEqA== - -pino@^8.11.0: - version "8.11.0" - resolved "https://registry.yarnpkg.com/pino/-/pino-8.11.0.tgz#2a91f454106b13e708a66c74ebc1c2ab7ab38498" - integrity sha512-Z2eKSvlrl2rH8p5eveNUnTdd4AjJk8tAsLkHYZQKGHP4WTh2Gi1cOSOs3eWPqaj+niS3gj4UkoreoaWgF3ZWYg== - dependencies: - atomic-sleep "^1.0.0" - fast-redact "^3.1.1" - on-exit-leak-free "^2.1.0" - pino-abstract-transport v1.0.0 - pino-std-serializers "^6.0.0" - process-warning "^2.0.0" - quick-format-unescaped "^4.0.3" - real-require "^0.2.0" - safe-stable-stringify "^2.3.1" - sonic-boom "^3.1.0" - thread-stream "^2.0.0" - -process-warning@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.2.0.tgz#008ec76b579820a8e5c35d81960525ca64feb626" - integrity sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg== - -process@^0.11.10: - version "0.11.10" - resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" - integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -quick-format-unescaped@^4.0.3: - version "4.0.4" - resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" - integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== - -readable-stream@^3.6.0: - version "3.6.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readable-stream@^4.0.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.3.0.tgz#0914d0c72db03b316c9733bb3461d64a3cc50cba" - integrity sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ== - dependencies: - abort-controller "^3.0.0" - buffer "^6.0.3" - events "^3.3.0" - process "^0.11.10" - -real-require@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" - integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== - -safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-stable-stringify@^2.3.1: - version "2.4.3" - resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886" - integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g== - -secure-json-parse@^2.4.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" - integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== - -sonic-boom@^3.0.0, sonic-boom@^3.1.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-3.3.0.tgz#cffab6dafee3b2bcb88d08d589394198bee1838c" - integrity sha512-LYxp34KlZ1a2Jb8ZQgFCK3niIHzibdwtwNUWKg0qQRzsDoJ3Gfgkf8KdBTFU3SkejDEIlWwnSnpVdOZIhFMl/g== - dependencies: - atomic-sleep "^1.0.0" - -split2@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" - integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== - -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -thread-stream@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.3.0.tgz#4fc07fb39eff32ae7bad803cb7dd9598349fed33" - integrity sha512-kaDqm1DET9pp3NXwR8382WHbnpXnRkN9xGN9dQt3B2+dmXiW8X1SOwmFOxAErEQ47ObhZ96J6yhZNXuyCOL7KA== - dependencies: - real-require "^0.2.0" - -util-deprecate@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== diff --git a/packages/server-commands/package.json b/packages/server-commands/package.json new file mode 100644 index 000000000..df9778198 --- /dev/null +++ b/packages/server-commands/package.json @@ -0,0 +1,19 @@ +{ + "name": "@peertube/peertube-server-commands", + "private": true, + "version": "0.0.0", + "main": "dist/index.js", + "files": [ "dist" ], + "exports": { + "types": "./dist/index.d.ts", + "peertube:tsx": "./src/index.ts", + "default": "./dist/index.js" + }, + "type": "module", + "devDependencies": {}, + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "dependencies": {} +} diff --git a/packages/server-commands/src/bulk/bulk-command.ts b/packages/server-commands/src/bulk/bulk-command.ts new file mode 100644 index 000000000..784836e19 --- /dev/null +++ b/packages/server-commands/src/bulk/bulk-command.ts @@ -0,0 +1,20 @@ +import { BulkRemoveCommentsOfBody, HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class BulkCommand extends AbstractCommand { + + removeCommentsOf (options: OverrideCommandOptions & { + attributes: BulkRemoveCommentsOfBody + }) { + const { attributes } = options + + return this.postBodyRequest({ + ...options, + + path: '/api/v1/bulk/remove-comments-of', + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/bulk/index.ts b/packages/server-commands/src/bulk/index.ts new file mode 100644 index 000000000..903f7a282 --- /dev/null +++ b/packages/server-commands/src/bulk/index.ts @@ -0,0 +1 @@ +export * from './bulk-command.js' diff --git a/packages/server-commands/src/cli/cli-command.ts b/packages/server-commands/src/cli/cli-command.ts new file mode 100644 index 000000000..8b9400c85 --- /dev/null +++ b/packages/server-commands/src/cli/cli-command.ts @@ -0,0 +1,27 @@ +import { exec } from 'child_process' +import { AbstractCommand } from '../shared/index.js' + +export class CLICommand extends AbstractCommand { + + static exec (command: string) { + return new Promise((res, rej) => { + exec(command, (err, stdout, _stderr) => { + if (err) return rej(err) + + return res(stdout) + }) + }) + } + + getEnv () { + return `NODE_ENV=test NODE_APP_INSTANCE=${this.server.internalServerNumber}` + } + + async execWithEnv (command: string, configOverride?: any) { + const prefix = configOverride + ? `NODE_CONFIG='${JSON.stringify(configOverride)}'` + : '' + + return CLICommand.exec(`${prefix} ${this.getEnv()} ${command}`) + } +} diff --git a/packages/server-commands/src/cli/index.ts b/packages/server-commands/src/cli/index.ts new file mode 100644 index 000000000..d79b13a76 --- /dev/null +++ b/packages/server-commands/src/cli/index.ts @@ -0,0 +1 @@ +export * from './cli-command.js' diff --git a/packages/server-commands/src/custom-pages/custom-pages-command.ts b/packages/server-commands/src/custom-pages/custom-pages-command.ts new file mode 100644 index 000000000..412f3f763 --- /dev/null +++ b/packages/server-commands/src/custom-pages/custom-pages-command.ts @@ -0,0 +1,33 @@ +import { CustomPage, HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class CustomPagesCommand extends AbstractCommand { + + getInstanceHomepage (options: OverrideCommandOptions = {}) { + const path = '/api/v1/custom-pages/homepage/instance' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateInstanceHomepage (options: OverrideCommandOptions & { + content: string + }) { + const { content } = options + const path = '/api/v1/custom-pages/homepage/instance' + + return this.putBodyRequest({ + ...options, + + path, + fields: { content }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/custom-pages/index.ts b/packages/server-commands/src/custom-pages/index.ts new file mode 100644 index 000000000..67f537f07 --- /dev/null +++ b/packages/server-commands/src/custom-pages/index.ts @@ -0,0 +1 @@ +export * from './custom-pages-command.js' diff --git a/packages/server-commands/src/feeds/feeds-command.ts b/packages/server-commands/src/feeds/feeds-command.ts new file mode 100644 index 000000000..51bc45b7f --- /dev/null +++ b/packages/server-commands/src/feeds/feeds-command.ts @@ -0,0 +1,78 @@ +import { buildUUID } from '@peertube/peertube-node-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +type FeedType = 'videos' | 'video-comments' | 'subscriptions' + +export class FeedCommand extends AbstractCommand { + + getXML (options: OverrideCommandOptions & { + feed: FeedType + ignoreCache: boolean + format?: string + }) { + const { feed, format, ignoreCache } = options + const path = '/feeds/' + feed + '.xml' + + const query: { [id: string]: string } = {} + + if (ignoreCache) query.v = buildUUID() + if (format) query.format = format + + return this.getRequestText({ + ...options, + + path, + query, + accept: 'application/xml', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPodcastXML (options: OverrideCommandOptions & { + ignoreCache: boolean + channelId: number + }) { + const { ignoreCache, channelId } = options + const path = `/feeds/podcast/videos.xml` + + const query: { [id: string]: string } = {} + + if (ignoreCache) query.v = buildUUID() + if (channelId) query.videoChannelId = channelId + '' + + return this.getRequestText({ + ...options, + + path, + query, + accept: 'application/xml', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getJSON (options: OverrideCommandOptions & { + feed: FeedType + ignoreCache: boolean + query?: { [ id: string ]: any } + }) { + const { feed, query = {}, ignoreCache } = options + const path = '/feeds/' + feed + '.json' + + const cacheQuery = ignoreCache + ? { v: buildUUID() } + : {} + + return this.getRequestText({ + ...options, + + path, + query: { ...query, ...cacheQuery }, + accept: 'application/json', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/feeds/index.ts b/packages/server-commands/src/feeds/index.ts new file mode 100644 index 000000000..316ebb974 --- /dev/null +++ b/packages/server-commands/src/feeds/index.ts @@ -0,0 +1 @@ +export * from './feeds-command.js' diff --git a/packages/server-commands/src/index.ts b/packages/server-commands/src/index.ts new file mode 100644 index 000000000..382fe966e --- /dev/null +++ b/packages/server-commands/src/index.ts @@ -0,0 +1,14 @@ +export * from './bulk/index.js' +export * from './cli/index.js' +export * from './custom-pages/index.js' +export * from './feeds/index.js' +export * from './logs/index.js' +export * from './moderation/index.js' +export * from './overviews/index.js' +export * from './requests/index.js' +export * from './runners/index.js' +export * from './search/index.js' +export * from './server/index.js' +export * from './socket/index.js' +export * from './users/index.js' +export * from './videos/index.js' diff --git a/packages/server-commands/src/logs/index.ts b/packages/server-commands/src/logs/index.ts new file mode 100644 index 000000000..37e77901c --- /dev/null +++ b/packages/server-commands/src/logs/index.ts @@ -0,0 +1 @@ +export * from './logs-command.js' diff --git a/packages/server-commands/src/logs/logs-command.ts b/packages/server-commands/src/logs/logs-command.ts new file mode 100644 index 000000000..d5d11b997 --- /dev/null +++ b/packages/server-commands/src/logs/logs-command.ts @@ -0,0 +1,56 @@ +import { ClientLogCreate, HttpStatusCode, ServerLogLevel } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class LogsCommand extends AbstractCommand { + + createLogClient (options: OverrideCommandOptions & { payload: ClientLogCreate }) { + const path = '/api/v1/server/logs/client' + + return this.postBodyRequest({ + ...options, + + path, + fields: options.payload, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getLogs (options: OverrideCommandOptions & { + startDate: Date + endDate?: Date + level?: ServerLogLevel + tagsOneOf?: string[] + }) { + const { startDate, endDate, tagsOneOf, level } = options + const path = '/api/v1/server/logs' + + return this.getRequestBody({ + ...options, + + path, + query: { startDate, endDate, level, tagsOneOf }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getAuditLogs (options: OverrideCommandOptions & { + startDate: Date + endDate?: Date + }) { + const { startDate, endDate } = options + + const path = '/api/v1/server/audit-logs' + + return this.getRequestBody({ + ...options, + + path, + query: { startDate, endDate }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/packages/server-commands/src/moderation/abuses-command.ts b/packages/server-commands/src/moderation/abuses-command.ts new file mode 100644 index 000000000..e267709e2 --- /dev/null +++ b/packages/server-commands/src/moderation/abuses-command.ts @@ -0,0 +1,228 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + AbuseFilter, + AbuseMessage, + AbusePredefinedReasonsString, + AbuseStateType, + AbuseUpdate, + AbuseVideoIs, + AdminAbuse, + HttpStatusCode, + ResultList, + UserAbuse +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/requests.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class AbusesCommand extends AbstractCommand { + + report (options: OverrideCommandOptions & { + reason: string + + accountId?: number + videoId?: number + commentId?: number + + predefinedReasons?: AbusePredefinedReasonsString[] + + startAt?: number + endAt?: number + }) { + const path = '/api/v1/abuses' + + const video = options.videoId + ? { + id: options.videoId, + startAt: options.startAt, + endAt: options.endAt + } + : undefined + + const comment = options.commentId + ? { id: options.commentId } + : undefined + + const account = options.accountId + ? { id: options.accountId } + : undefined + + const body = { + account, + video, + comment, + + reason: options.reason, + predefinedReasons: options.predefinedReasons + } + + return unwrapBody<{ abuse: { id: number } }>(this.postBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + getAdminList (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + + id?: number + predefinedReason?: AbusePredefinedReasonsString + search?: string + filter?: AbuseFilter + state?: AbuseStateType + videoIs?: AbuseVideoIs + searchReporter?: string + searchReportee?: string + searchVideo?: string + searchVideoChannel?: string + } = {}) { + const toPick: (keyof typeof options)[] = [ + 'count', + 'filter', + 'id', + 'predefinedReason', + 'search', + 'searchReportee', + 'searchReporter', + 'searchVideo', + 'searchVideoChannel', + 'sort', + 'start', + 'state', + 'videoIs' + ] + + const path = '/api/v1/abuses' + + const defaultQuery = { sort: 'createdAt' } + const query = { ...defaultQuery, ...pick(options, toPick) } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getUserList (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + + id?: number + search?: string + state?: AbuseStateType + }) { + const toPick: (keyof typeof options)[] = [ + 'id', + 'search', + 'state', + 'start', + 'count', + 'sort' + ] + + const path = '/api/v1/users/me/abuses' + + const defaultQuery = { sort: 'createdAt' } + const query = { ...defaultQuery, ...pick(options, toPick) } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + update (options: OverrideCommandOptions & { + abuseId: number + body: AbuseUpdate + }) { + const { abuseId, body } = options + const path = '/api/v1/abuses/' + abuseId + + return this.putBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + abuseId: number + }) { + const { abuseId } = options + const path = '/api/v1/abuses/' + abuseId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + listMessages (options: OverrideCommandOptions & { + abuseId: number + }) { + const { abuseId } = options + const path = '/api/v1/abuses/' + abuseId + '/messages' + + return this.getRequestBody>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteMessage (options: OverrideCommandOptions & { + abuseId: number + messageId: number + }) { + const { abuseId, messageId } = options + const path = '/api/v1/abuses/' + abuseId + '/messages/' + messageId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + addMessage (options: OverrideCommandOptions & { + abuseId: number + message: string + }) { + const { abuseId, message } = options + const path = '/api/v1/abuses/' + abuseId + '/messages' + + return this.postBodyRequest({ + ...options, + + path, + fields: { message }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/packages/server-commands/src/moderation/index.ts b/packages/server-commands/src/moderation/index.ts new file mode 100644 index 000000000..8164afd7c --- /dev/null +++ b/packages/server-commands/src/moderation/index.ts @@ -0,0 +1 @@ +export * from './abuses-command.js' diff --git a/packages/server-commands/src/overviews/index.ts b/packages/server-commands/src/overviews/index.ts new file mode 100644 index 000000000..54c90705a --- /dev/null +++ b/packages/server-commands/src/overviews/index.ts @@ -0,0 +1 @@ +export * from './overviews-command.js' diff --git a/packages/server-commands/src/overviews/overviews-command.ts b/packages/server-commands/src/overviews/overviews-command.ts new file mode 100644 index 000000000..decd2fd8e --- /dev/null +++ b/packages/server-commands/src/overviews/overviews-command.ts @@ -0,0 +1,23 @@ +import { HttpStatusCode, VideosOverview } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class OverviewsCommand extends AbstractCommand { + + getVideos (options: OverrideCommandOptions & { + page: number + }) { + const { page } = options + const path = '/api/v1/overviews/videos' + + const query = { page } + + return this.getRequestBody({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/requests/index.ts b/packages/server-commands/src/requests/index.ts new file mode 100644 index 000000000..4c818659e --- /dev/null +++ b/packages/server-commands/src/requests/index.ts @@ -0,0 +1 @@ +export * from './requests.js' diff --git a/packages/server-commands/src/requests/requests.ts b/packages/server-commands/src/requests/requests.ts new file mode 100644 index 000000000..ac143ea5d --- /dev/null +++ b/packages/server-commands/src/requests/requests.ts @@ -0,0 +1,260 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ + +import { decode } from 'querystring' +import request from 'supertest' +import { URL } from 'url' +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, HttpStatusCodeType } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' + +export type CommonRequestParams = { + url: string + path?: string + contentType?: string + responseType?: string + range?: string + redirects?: number + accept?: string + host?: string + token?: string + headers?: { [ name: string ]: string } + type?: string + xForwardedFor?: string + expectedStatus?: HttpStatusCodeType +} + +function makeRawRequest (options: { + url: string + token?: string + expectedStatus?: HttpStatusCodeType + range?: string + query?: { [ id: string ]: string } + method?: 'GET' | 'POST' + headers?: { [ name: string ]: string } +}) { + const { host, protocol, pathname } = new URL(options.url) + + const reqOptions = { + url: `${protocol}//${host}`, + path: pathname, + contentType: undefined, + + ...pick(options, [ 'expectedStatus', 'range', 'token', 'query', 'headers' ]) + } + + if (options.method === 'POST') { + return makePostBodyRequest(reqOptions) + } + + return makeGetRequest(reqOptions) +} + +function makeGetRequest (options: CommonRequestParams & { + query?: any + rawQuery?: string +}) { + const req = request(options.url).get(options.path) + + if (options.query) req.query(options.query) + if (options.rawQuery) req.query(options.rawQuery) + + return buildRequest(req, { contentType: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function makeHTMLRequest (url: string, path: string) { + return makeGetRequest({ + url, + path, + accept: 'text/html', + expectedStatus: HttpStatusCode.OK_200 + }) +} + +function makeActivityPubGetRequest (url: string, path: string, expectedStatus: HttpStatusCodeType = HttpStatusCode.OK_200) { + return makeGetRequest({ + url, + path, + expectedStatus, + accept: 'application/activity+json,text/html;q=0.9,\\*/\\*;q=0.8' + }) +} + +function makeDeleteRequest (options: CommonRequestParams & { + query?: any + rawQuery?: string +}) { + const req = request(options.url).delete(options.path) + + if (options.query) req.query(options.query) + if (options.rawQuery) req.query(options.rawQuery) + + return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function makeUploadRequest (options: CommonRequestParams & { + method?: 'POST' | 'PUT' + + fields: { [ fieldName: string ]: any } + attaches?: { [ attachName: string ]: any | any[] } +}) { + let req = options.method === 'PUT' + ? request(options.url).put(options.path) + : request(options.url).post(options.path) + + req = buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) + + buildFields(req, options.fields) + + Object.keys(options.attaches || {}).forEach(attach => { + const value = options.attaches[attach] + if (!value) return + + if (Array.isArray(value)) { + req.attach(attach, buildAbsoluteFixturePath(value[0]), value[1]) + } else { + req.attach(attach, buildAbsoluteFixturePath(value)) + } + }) + + return req +} + +function makePostBodyRequest (options: CommonRequestParams & { + fields?: { [ fieldName: string ]: any } +}) { + const req = request(options.url).post(options.path) + .send(options.fields) + + return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function makePutBodyRequest (options: { + url: string + path: string + token?: string + fields: { [ fieldName: string ]: any } + expectedStatus?: HttpStatusCodeType + headers?: { [name: string]: string } +}) { + const req = request(options.url).put(options.path) + .send(options.fields) + + return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function decodeQueryString (path: string) { + return decode(path.split('?')[1]) +} + +// --------------------------------------------------------------------------- + +function unwrapBody (test: request.Test): Promise { + return test.then(res => res.body) +} + +function unwrapText (test: request.Test): Promise { + return test.then(res => res.text) +} + +function unwrapBodyOrDecodeToJSON (test: request.Test): Promise { + return test.then(res => { + if (res.body instanceof Buffer) { + try { + return JSON.parse(new TextDecoder().decode(res.body)) + } catch (err) { + console.error('Cannot decode JSON.', { res, body: res.body instanceof Buffer ? res.body.toString() : res.body }) + throw err + } + } + + if (res.text) { + try { + return JSON.parse(res.text) + } catch (err) { + console.error('Cannot decode json', { res, text: res.text }) + throw err + } + } + + return res.body + }) +} + +function unwrapTextOrDecode (test: request.Test): Promise { + return test.then(res => res.text || new TextDecoder().decode(res.body)) +} + +// --------------------------------------------------------------------------- + +export { + makeHTMLRequest, + makeGetRequest, + decodeQueryString, + makeUploadRequest, + makePostBodyRequest, + makePutBodyRequest, + makeDeleteRequest, + makeRawRequest, + makeActivityPubGetRequest, + unwrapBody, + unwrapTextOrDecode, + unwrapBodyOrDecodeToJSON, + unwrapText +} + +// --------------------------------------------------------------------------- + +function buildRequest (req: request.Test, options: CommonRequestParams) { + if (options.contentType) req.set('Accept', options.contentType) + if (options.responseType) req.responseType(options.responseType) + if (options.token) req.set('Authorization', 'Bearer ' + options.token) + if (options.range) req.set('Range', options.range) + if (options.accept) req.set('Accept', options.accept) + if (options.host) req.set('Host', options.host) + if (options.redirects) req.redirects(options.redirects) + if (options.xForwardedFor) req.set('X-Forwarded-For', options.xForwardedFor) + if (options.type) req.type(options.type) + + Object.keys(options.headers || {}).forEach(name => { + req.set(name, options.headers[name]) + }) + + return req.expect(res => { + if (options.expectedStatus && res.status !== options.expectedStatus) { + const err = new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` + + `\nThe server responded: "${res.body?.error ?? res.text}".\n` + + 'You may take a closer look at the logs. To see how to do so, check out this page: ' + + 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs'); + + (err as any).res = res + + throw err + } + + return res + }) +} + +function buildFields (req: request.Test, fields: { [ fieldName: string ]: any }, namespace?: string) { + if (!fields) return + + let formKey: string + + for (const key of Object.keys(fields)) { + if (namespace) formKey = `${namespace}[${key}]` + else formKey = key + + if (fields[key] === undefined) continue + + if (Array.isArray(fields[key]) && fields[key].length === 0) { + req.field(key, []) + continue + } + + if (fields[key] !== null && typeof fields[key] === 'object') { + buildFields(req, fields[key], formKey) + } else { + req.field(formKey, fields[key]) + } + } +} diff --git a/packages/server-commands/src/runners/index.ts b/packages/server-commands/src/runners/index.ts new file mode 100644 index 000000000..c868fa78e --- /dev/null +++ b/packages/server-commands/src/runners/index.ts @@ -0,0 +1,3 @@ +export * from './runner-jobs-command.js' +export * from './runner-registration-tokens-command.js' +export * from './runners-command.js' diff --git a/packages/server-commands/src/runners/runner-jobs-command.ts b/packages/server-commands/src/runners/runner-jobs-command.ts new file mode 100644 index 000000000..4e702199f --- /dev/null +++ b/packages/server-commands/src/runners/runner-jobs-command.ts @@ -0,0 +1,297 @@ +import { omit, pick, wait } from '@peertube/peertube-core-utils' +import { + AbortRunnerJobBody, + AcceptRunnerJobBody, + AcceptRunnerJobResult, + ErrorRunnerJobBody, + HttpStatusCode, + isHLSTranscodingPayloadSuccess, + isLiveRTMPHLSTranscodingUpdatePayload, + isWebVideoOrAudioMergeTranscodingPayloadSuccess, + ListRunnerJobsQuery, + RequestRunnerJobBody, + RequestRunnerJobResult, + ResultList, + RunnerJobAdmin, + RunnerJobLiveRTMPHLSTranscodingPayload, + RunnerJobPayload, + RunnerJobState, + RunnerJobStateType, + RunnerJobSuccessBody, + RunnerJobSuccessPayload, + RunnerJobType, + RunnerJobUpdateBody, + RunnerJobVODPayload, + VODHLSTranscodingSuccess, + VODWebVideoTranscodingSuccess +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { waitJobs } from '../server/jobs.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RunnerJobsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & ListRunnerJobsQuery = {}) { + const path = '/api/v1/runners/jobs' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search', 'stateOneOf' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + cancelByAdmin (options: OverrideCommandOptions & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/cancel' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + deleteByAdmin (options: OverrideCommandOptions & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + request (options: OverrideCommandOptions & RequestRunnerJobBody) { + const path = '/api/v1/runners/jobs/request' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + async requestVOD (options: OverrideCommandOptions & RequestRunnerJobBody) { + const vodTypes = new Set([ 'vod-audio-merge-transcoding', 'vod-hls-transcoding', 'vod-web-video-transcoding' ]) + + const { availableJobs } = await this.request(options) + + return { + availableJobs: availableJobs.filter(j => vodTypes.has(j.type)) + } as RequestRunnerJobResult + } + + async requestLive (options: OverrideCommandOptions & RequestRunnerJobBody) { + const vodTypes = new Set([ 'live-rtmp-hls-transcoding' ]) + + const { availableJobs } = await this.request(options) + + return { + availableJobs: availableJobs.filter(j => vodTypes.has(j.type)) + } as RequestRunnerJobResult + } + + // --------------------------------------------------------------------------- + + accept (options: OverrideCommandOptions & AcceptRunnerJobBody & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/accept' + + return unwrapBody>(this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + abort (options: OverrideCommandOptions & AbortRunnerJobBody & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/abort' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'reason', 'jobToken', 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + update (options: OverrideCommandOptions & RunnerJobUpdateBody & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/update' + + const { payload } = options + const attaches: { [id: string]: any } = {} + let payloadWithoutFiles = payload + + if (isLiveRTMPHLSTranscodingUpdatePayload(payload)) { + if (payload.masterPlaylistFile) { + attaches[`payload[masterPlaylistFile]`] = payload.masterPlaylistFile + } + + attaches[`payload[resolutionPlaylistFile]`] = payload.resolutionPlaylistFile + attaches[`payload[videoChunkFile]`] = payload.videoChunkFile + + payloadWithoutFiles = omit(payloadWithoutFiles, [ 'masterPlaylistFile', 'resolutionPlaylistFile', 'videoChunkFile' ]) + } + + return this.postUploadRequest({ + ...options, + + path, + fields: { + ...pick(options, [ 'progress', 'jobToken', 'runnerToken' ]), + + payload: payloadWithoutFiles + }, + attaches, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + error (options: OverrideCommandOptions & ErrorRunnerJobBody & { jobUUID: string }) { + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/error' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'message', 'jobToken', 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + success (options: OverrideCommandOptions & RunnerJobSuccessBody & { jobUUID: string }) { + const { payload } = options + + const path = '/api/v1/runners/jobs/' + options.jobUUID + '/success' + const attaches: { [id: string]: any } = {} + let payloadWithoutFiles = payload + + if ((isWebVideoOrAudioMergeTranscodingPayloadSuccess(payload) || isHLSTranscodingPayloadSuccess(payload)) && payload.videoFile) { + attaches[`payload[videoFile]`] = payload.videoFile + + payloadWithoutFiles = omit(payloadWithoutFiles as VODWebVideoTranscodingSuccess, [ 'videoFile' ]) + } + + if (isHLSTranscodingPayloadSuccess(payload) && payload.resolutionPlaylistFile) { + attaches[`payload[resolutionPlaylistFile]`] = payload.resolutionPlaylistFile + + payloadWithoutFiles = omit(payloadWithoutFiles as VODHLSTranscodingSuccess, [ 'resolutionPlaylistFile' ]) + } + + return this.postUploadRequest({ + ...options, + + path, + attaches, + fields: { + ...pick(options, [ 'jobToken', 'runnerToken' ]), + + payload: payloadWithoutFiles + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getJobFile (options: OverrideCommandOptions & { url: string, jobToken: string, runnerToken: string }) { + const { host, protocol, pathname } = new URL(options.url) + + return this.postBodyRequest({ + url: `${protocol}//${host}`, + path: pathname, + + fields: pick(options, [ 'jobToken', 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + async autoAccept (options: OverrideCommandOptions & RequestRunnerJobBody & { type?: RunnerJobType }) { + const { availableJobs } = await this.request(options) + + const job = options.type + ? availableJobs.find(j => j.type === options.type) + : availableJobs[0] + + return this.accept({ ...options, jobUUID: job.uuid }) + } + + async autoProcessWebVideoJob (runnerToken: string, jobUUIDToProcess?: string) { + let jobUUID = jobUUIDToProcess + + if (!jobUUID) { + const { availableJobs } = await this.request({ runnerToken }) + jobUUID = availableJobs[0].uuid + } + + const { job } = await this.accept({ runnerToken, jobUUID }) + const jobToken = job.jobToken + + const payload: RunnerJobSuccessPayload = { videoFile: 'video_short.mp4' } + await this.success({ runnerToken, jobUUID, jobToken, payload }) + + await waitJobs([ this.server ]) + + return job + } + + async cancelAllJobs (options: { state?: RunnerJobStateType } = {}) { + const { state } = options + + const { data } = await this.list({ count: 100 }) + + const allowedStates = new Set([ + RunnerJobState.PENDING, + RunnerJobState.PROCESSING, + RunnerJobState.WAITING_FOR_PARENT_JOB + ]) + + for (const job of data) { + if (state && job.state.id !== state) continue + else if (allowedStates.has(job.state.id) !== true) continue + + await this.cancelByAdmin({ jobUUID: job.uuid }) + } + } + + async getJob (options: OverrideCommandOptions & { uuid: string }) { + const { data } = await this.list({ ...options, count: 100, sort: '-updatedAt' }) + + return data.find(j => j.uuid === options.uuid) + } + + async requestLiveJob (runnerToken: string) { + let availableJobs: RequestRunnerJobResult['availableJobs'] = [] + + while (availableJobs.length === 0) { + const result = await this.requestLive({ runnerToken }) + availableJobs = result.availableJobs + + if (availableJobs.length === 1) break + + await wait(150) + } + + return availableJobs[0] + } +} diff --git a/packages/server-commands/src/runners/runner-registration-tokens-command.ts b/packages/server-commands/src/runners/runner-registration-tokens-command.ts new file mode 100644 index 000000000..86b6e5f93 --- /dev/null +++ b/packages/server-commands/src/runners/runner-registration-tokens-command.ts @@ -0,0 +1,55 @@ +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, ResultList, RunnerRegistrationToken } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RunnerRegistrationTokensCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + } = {}) { + const path = '/api/v1/runners/registration-tokens' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + generate (options: OverrideCommandOptions = {}) { + const path = '/api/v1/runners/registration-tokens/generate' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + id: number + }) { + const path = '/api/v1/runners/registration-tokens/' + options.id + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async getFirstRegistrationToken (options: OverrideCommandOptions = {}) { + const { data } = await this.list(options) + + return data[0].registrationToken + } +} diff --git a/packages/server-commands/src/runners/runners-command.ts b/packages/server-commands/src/runners/runners-command.ts new file mode 100644 index 000000000..376a1dff9 --- /dev/null +++ b/packages/server-commands/src/runners/runners-command.ts @@ -0,0 +1,85 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + RegisterRunnerBody, + RegisterRunnerResult, + ResultList, + Runner, + UnregisterRunnerBody +} from '@peertube/peertube-models' +import { buildUUID } from '@peertube/peertube-node-utils' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RunnersCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + } = {}) { + const path = '/api/v1/runners' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + register (options: OverrideCommandOptions & RegisterRunnerBody) { + const path = '/api/v1/runners/register' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'name', 'registrationToken', 'description' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + unregister (options: OverrideCommandOptions & UnregisterRunnerBody) { + const path = '/api/v1/runners/unregister' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'runnerToken' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + id: number + }) { + const path = '/api/v1/runners/' + options.id + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + async autoRegisterRunner () { + const { data } = await this.server.runnerRegistrationTokens.list({ sort: 'createdAt' }) + + const { runnerToken } = await this.register({ + name: 'runner ' + buildUUID(), + registrationToken: data[0].registrationToken + }) + + return runnerToken + } +} diff --git a/packages/server-commands/src/search/index.ts b/packages/server-commands/src/search/index.ts new file mode 100644 index 000000000..ca56fc669 --- /dev/null +++ b/packages/server-commands/src/search/index.ts @@ -0,0 +1 @@ +export * from './search-command.js' diff --git a/packages/server-commands/src/search/search-command.ts b/packages/server-commands/src/search/search-command.ts new file mode 100644 index 000000000..e766a2861 --- /dev/null +++ b/packages/server-commands/src/search/search-command.ts @@ -0,0 +1,98 @@ +import { + HttpStatusCode, + ResultList, + Video, + VideoChannel, + VideoChannelsSearchQuery, + VideoPlaylist, + VideoPlaylistsSearchQuery, + VideosSearchQuery +} from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class SearchCommand extends AbstractCommand { + + searchChannels (options: OverrideCommandOptions & { + search: string + }) { + return this.advancedChannelSearch({ + ...options, + + search: { search: options.search } + }) + } + + advancedChannelSearch (options: OverrideCommandOptions & { + search: VideoChannelsSearchQuery + }) { + const { search } = options + const path = '/api/v1/search/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + query: search, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + searchPlaylists (options: OverrideCommandOptions & { + search: string + }) { + return this.advancedPlaylistSearch({ + ...options, + + search: { search: options.search } + }) + } + + advancedPlaylistSearch (options: OverrideCommandOptions & { + search: VideoPlaylistsSearchQuery + }) { + const { search } = options + const path = '/api/v1/search/video-playlists' + + return this.getRequestBody>({ + ...options, + + path, + query: search, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + searchVideos (options: OverrideCommandOptions & { + search: string + sort?: string + }) { + const { search, sort } = options + + return this.advancedVideoSearch({ + ...options, + + search: { + search, + sort: sort ?? '-publishedAt' + } + }) + } + + advancedVideoSearch (options: OverrideCommandOptions & { + search: VideosSearchQuery + }) { + const { search } = options + const path = '/api/v1/search/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: search, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/server/config-command.ts b/packages/server-commands/src/server/config-command.ts new file mode 100644 index 000000000..8fcf0bd51 --- /dev/null +++ b/packages/server-commands/src/server/config-command.ts @@ -0,0 +1,576 @@ +import merge from 'lodash-es/merge.js' +import { About, CustomConfig, HttpStatusCode, ServerConfig } from '@peertube/peertube-models' +import { DeepPartial } from '@peertube/peertube-typescript-utils' +import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-command.js' + +export class ConfigCommand extends AbstractCommand { + + static getCustomConfigResolutions (enabled: boolean, with0p = false) { + return { + '0p': enabled && with0p, + '144p': enabled, + '240p': enabled, + '360p': enabled, + '480p': enabled, + '720p': enabled, + '1080p': enabled, + '1440p': enabled, + '2160p': enabled + } + } + + // --------------------------------------------------------------------------- + + static getEmailOverrideConfig (emailPort: number) { + return { + smtp: { + hostname: '127.0.0.1', + port: emailPort + } + } + } + + // --------------------------------------------------------------------------- + + enableSignup (requiresApproval: boolean, limit = -1) { + return this.updateExistingSubConfig({ + newConfig: { + signup: { + enabled: true, + requiresApproval, + limit + } + } + }) + } + + // --------------------------------------------------------------------------- + + disableImports () { + return this.setImportsEnabled(false) + } + + enableImports () { + return this.setImportsEnabled(true) + } + + private setImportsEnabled (enabled: boolean) { + return this.updateExistingSubConfig({ + newConfig: { + import: { + videos: { + http: { + enabled + }, + + torrent: { + enabled + } + } + } + } + }) + } + + // --------------------------------------------------------------------------- + + disableFileUpdate () { + return this.setFileUpdateEnabled(false) + } + + enableFileUpdate () { + return this.setFileUpdateEnabled(true) + } + + private setFileUpdateEnabled (enabled: boolean) { + return this.updateExistingSubConfig({ + newConfig: { + videoFile: { + update: { + enabled + } + } + } + }) + } + + // --------------------------------------------------------------------------- + + enableChannelSync () { + return this.setChannelSyncEnabled(true) + } + + disableChannelSync () { + return this.setChannelSyncEnabled(false) + } + + private setChannelSyncEnabled (enabled: boolean) { + return this.updateExistingSubConfig({ + newConfig: { + import: { + videoChannelSynchronization: { + enabled + } + } + } + }) + } + + // --------------------------------------------------------------------------- + + enableLive (options: { + allowReplay?: boolean + transcoding?: boolean + resolutions?: 'min' | 'max' // Default max + } = {}) { + const { allowReplay, transcoding, resolutions = 'max' } = options + + return this.updateExistingSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: allowReplay ?? true, + transcoding: { + enabled: transcoding ?? true, + resolutions: ConfigCommand.getCustomConfigResolutions(resolutions === 'max') + } + } + } + }) + } + + disableTranscoding () { + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: false + }, + videoStudio: { + enabled: false + } + } + }) + } + + enableTranscoding (options: { + webVideo?: boolean // default true + hls?: boolean // default true + with0p?: boolean // default false + } = {}) { + const { webVideo = true, hls = true, with0p = false } = options + + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: true, + + allowAudioFiles: true, + allowAdditionalExtensions: true, + + resolutions: ConfigCommand.getCustomConfigResolutions(true, with0p), + + webVideos: { + enabled: webVideo + }, + hls: { + enabled: hls + } + } + } + }) + } + + enableMinimumTranscoding (options: { + webVideo?: boolean // default true + hls?: boolean // default true + } = {}) { + const { webVideo = true, hls = true } = options + + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: true, + + allowAudioFiles: true, + allowAdditionalExtensions: true, + + resolutions: { + ...ConfigCommand.getCustomConfigResolutions(false), + + '240p': true + }, + + webVideos: { + enabled: webVideo + }, + hls: { + enabled: hls + } + } + } + }) + } + + enableRemoteTranscoding () { + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + remoteRunners: { + enabled: true + } + }, + live: { + transcoding: { + remoteRunners: { + enabled: true + } + } + } + } + }) + } + + enableRemoteStudio () { + return this.updateExistingSubConfig({ + newConfig: { + videoStudio: { + remoteRunners: { + enabled: true + } + } + } + }) + } + + // --------------------------------------------------------------------------- + + enableStudio () { + return this.updateExistingSubConfig({ + newConfig: { + videoStudio: { + enabled: true + } + } + }) + } + + // --------------------------------------------------------------------------- + + getConfig (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async getIndexHTMLConfig (options: OverrideCommandOptions = {}) { + const text = await this.getRequestText({ + ...options, + + path: '/', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + + const match = text.match('') + + // We parse the string twice, first to extract the string and then to extract the JSON + return JSON.parse(JSON.parse(match[1])) as ServerConfig + } + + getAbout (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config/about' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getCustomConfig (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config/custom' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateCustomConfig (options: OverrideCommandOptions & { + newCustomConfig: CustomConfig + }) { + const path = '/api/v1/config/custom' + + return this.putBodyRequest({ + ...options, + + path, + fields: options.newCustomConfig, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteCustomConfig (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config/custom' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async updateExistingSubConfig (options: OverrideCommandOptions & { + newConfig: DeepPartial + }) { + const existing = await this.getCustomConfig({ ...options, expectedStatus: HttpStatusCode.OK_200 }) + + return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) }) + } + + updateCustomSubConfig (options: OverrideCommandOptions & { + newConfig: DeepPartial + }) { + const newCustomConfig: CustomConfig = { + instance: { + name: 'PeerTube updated', + shortDescription: 'my short description', + description: 'my super description', + terms: 'my super terms', + codeOfConduct: 'my super coc', + + creationReason: 'my super creation reason', + moderationInformation: 'my super moderation information', + administrator: 'Kuja', + maintenanceLifetime: 'forever', + businessModel: 'my super business model', + hardwareInformation: '2vCore 3GB RAM', + + languages: [ 'en', 'es' ], + categories: [ 1, 2 ], + + isNSFW: true, + defaultNSFWPolicy: 'blur', + + defaultClientRoute: '/videos/recently-added', + + customizations: { + javascript: 'alert("coucou")', + css: 'body { background-color: red; }' + } + }, + theme: { + default: 'default' + }, + services: { + twitter: { + username: '@MySuperUsername', + whitelisted: true + } + }, + client: { + videos: { + miniature: { + preferAuthorDisplayName: false + } + }, + menu: { + login: { + redirectOnSingleExternalAuth: false + } + } + }, + cache: { + previews: { + size: 2 + }, + captions: { + size: 3 + }, + torrents: { + size: 4 + }, + storyboards: { + size: 5 + } + }, + signup: { + enabled: false, + limit: 5, + requiresApproval: true, + requiresEmailVerification: false, + minimumAge: 16 + }, + admin: { + email: 'superadmin1@example.com' + }, + contactForm: { + enabled: true + }, + user: { + history: { + videos: { + enabled: true + } + }, + videoQuota: 5242881, + videoQuotaDaily: 318742 + }, + videoChannels: { + maxPerUser: 20 + }, + transcoding: { + enabled: true, + remoteRunners: { + enabled: false + }, + allowAdditionalExtensions: true, + allowAudioFiles: true, + threads: 1, + concurrency: 3, + profile: 'default', + resolutions: { + '0p': false, + '144p': false, + '240p': false, + '360p': true, + '480p': true, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + alwaysTranscodeOriginalResolution: true, + webVideos: { + enabled: true + }, + hls: { + enabled: false + } + }, + live: { + enabled: true, + allowReplay: false, + latencySetting: { + enabled: false + }, + maxDuration: -1, + maxInstanceLives: -1, + maxUserLives: 50, + transcoding: { + enabled: true, + remoteRunners: { + enabled: false + }, + threads: 4, + profile: 'default', + resolutions: { + '144p': true, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + }, + alwaysTranscodeOriginalResolution: true + } + }, + videoStudio: { + enabled: false, + remoteRunners: { + enabled: false + } + }, + videoFile: { + update: { + enabled: false + } + }, + import: { + videos: { + concurrency: 3, + http: { + enabled: false + }, + torrent: { + enabled: false + } + }, + videoChannelSynchronization: { + enabled: false, + maxPerUser: 10 + } + }, + trending: { + videos: { + algorithms: { + enabled: [ 'hot', 'most-viewed', 'most-liked' ], + default: 'hot' + } + } + }, + autoBlacklist: { + videos: { + ofUsers: { + enabled: false + } + } + }, + followers: { + instance: { + enabled: true, + manualApproval: false + } + }, + followings: { + instance: { + autoFollowBack: { + enabled: false + }, + autoFollowIndex: { + indexUrl: 'https://instances.joinpeertube.org/api/v1/instances/hosts', + enabled: false + } + } + }, + broadcastMessage: { + enabled: true, + level: 'warning', + message: 'hello', + dismissable: true + }, + search: { + remoteUri: { + users: true, + anonymous: true + }, + searchIndex: { + enabled: true, + url: 'https://search.joinpeertube.org', + disableLocalSearch: true, + isDefaultSearch: true + } + } + } + + merge(newCustomConfig, options.newConfig) + + return this.updateCustomConfig({ ...options, newCustomConfig }) + } +} diff --git a/packages/server-commands/src/server/contact-form-command.ts b/packages/server-commands/src/server/contact-form-command.ts new file mode 100644 index 000000000..399e06d2f --- /dev/null +++ b/packages/server-commands/src/server/contact-form-command.ts @@ -0,0 +1,30 @@ +import { ContactForm, HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ContactFormCommand extends AbstractCommand { + + send (options: OverrideCommandOptions & { + fromEmail: string + fromName: string + subject: string + body: string + }) { + const path = '/api/v1/server/contact' + + const body: ContactForm = { + fromEmail: options.fromEmail, + fromName: options.fromName, + subject: options.subject, + body: options.body + } + + return this.postBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/debug-command.ts b/packages/server-commands/src/server/debug-command.ts new file mode 100644 index 000000000..9bb7fda10 --- /dev/null +++ b/packages/server-commands/src/server/debug-command.ts @@ -0,0 +1,33 @@ +import { Debug, HttpStatusCode, SendDebugCommand } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class DebugCommand extends AbstractCommand { + + getDebug (options: OverrideCommandOptions = {}) { + const path = '/api/v1/server/debug' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + sendCommand (options: OverrideCommandOptions & { + body: SendDebugCommand + }) { + const { body } = options + const path = '/api/v1/server/debug/run-command' + + return this.postBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/follows-command.ts b/packages/server-commands/src/server/follows-command.ts new file mode 100644 index 000000000..cdc263982 --- /dev/null +++ b/packages/server-commands/src/server/follows-command.ts @@ -0,0 +1,139 @@ +import { pick } from '@peertube/peertube-core-utils' +import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList, ServerFollowCreate } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' +import { PeerTubeServer } from './server.js' + +export class FollowsCommand extends AbstractCommand { + + getFollowers (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + actorType?: ActivityPubActorType + state?: FollowState + } = {}) { + const path = '/api/v1/server/followers' + + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getFollowings (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + actorType?: ActivityPubActorType + state?: FollowState + } = {}) { + const path = '/api/v1/server/following' + + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + follow (options: OverrideCommandOptions & { + hosts?: string[] + handles?: string[] + }) { + const path = '/api/v1/server/following' + + const fields: ServerFollowCreate = {} + + if (options.hosts) { + fields.hosts = options.hosts.map(f => f.replace(/^http:\/\//, '')) + } + + if (options.handles) { + fields.handles = options.handles + } + + return this.postBodyRequest({ + ...options, + + path, + fields, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async unfollow (options: OverrideCommandOptions & { + target: PeerTubeServer | string + }) { + const { target } = options + + const handle = typeof target === 'string' + ? target + : target.host + + const path = '/api/v1/server/following/' + handle + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + acceptFollower (options: OverrideCommandOptions & { + follower: string + }) { + const path = '/api/v1/server/followers/' + options.follower + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + rejectFollower (options: OverrideCommandOptions & { + follower: string + }) { + const path = '/api/v1/server/followers/' + options.follower + '/reject' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeFollower (options: OverrideCommandOptions & { + follower: PeerTubeServer + }) { + const path = '/api/v1/server/followers/peertube@' + options.follower.host + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/follows.ts b/packages/server-commands/src/server/follows.ts new file mode 100644 index 000000000..32304495a --- /dev/null +++ b/packages/server-commands/src/server/follows.ts @@ -0,0 +1,20 @@ +import { waitJobs } from './jobs.js' +import { PeerTubeServer } from './server.js' + +async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) { + await Promise.all([ + server1.follows.follow({ hosts: [ server2.url ] }), + server2.follows.follow({ hosts: [ server1.url ] }) + ]) + + // Wait request propagation + await waitJobs([ server1, server2 ]) + + return true +} + +// --------------------------------------------------------------------------- + +export { + doubleFollow +} diff --git a/packages/server-commands/src/server/index.ts b/packages/server-commands/src/server/index.ts new file mode 100644 index 000000000..c13972eca --- /dev/null +++ b/packages/server-commands/src/server/index.ts @@ -0,0 +1,15 @@ +export * from './config-command.js' +export * from './contact-form-command.js' +export * from './debug-command.js' +export * from './follows-command.js' +export * from './follows.js' +export * from './jobs.js' +export * from './jobs-command.js' +export * from './metrics-command.js' +export * from './object-storage-command.js' +export * from './plugins-command.js' +export * from './redundancy-command.js' +export * from './server.js' +export * from './servers-command.js' +export * from './servers.js' +export * from './stats-command.js' diff --git a/packages/server-commands/src/server/jobs-command.ts b/packages/server-commands/src/server/jobs-command.ts new file mode 100644 index 000000000..18aa0cd95 --- /dev/null +++ b/packages/server-commands/src/server/jobs-command.ts @@ -0,0 +1,84 @@ +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, Job, JobState, JobType, ResultList } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class JobsCommand extends AbstractCommand { + + async getLatest (options: OverrideCommandOptions & { + jobType: JobType + }) { + const { data } = await this.list({ ...options, start: 0, count: 1, sort: '-createdAt' }) + + if (data.length === 0) return undefined + + return data[0] + } + + pauseJobQueue (options: OverrideCommandOptions = {}) { + const path = '/api/v1/jobs/pause' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + resumeJobQueue (options: OverrideCommandOptions = {}) { + const path = '/api/v1/jobs/resume' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + state?: JobState + jobType?: JobType + start?: number + count?: number + sort?: string + } = {}) { + const path = this.buildJobsUrl(options.state) + + const query = pick(options, [ 'start', 'count', 'sort', 'jobType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listFailed (options: OverrideCommandOptions & { + jobType?: JobType + }) { + const path = this.buildJobsUrl('failed') + + return this.getRequestBody>({ + ...options, + + path, + query: { start: 0, count: 50 }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private buildJobsUrl (state?: JobState) { + let path = '/api/v1/jobs' + + if (state) path += '/' + state + + return path + } +} diff --git a/packages/server-commands/src/server/jobs.ts b/packages/server-commands/src/server/jobs.ts new file mode 100644 index 000000000..1f3b1f745 --- /dev/null +++ b/packages/server-commands/src/server/jobs.ts @@ -0,0 +1,117 @@ +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { JobState, JobType, RunnerJobState } from '@peertube/peertube-models' +import { PeerTubeServer } from './server.js' + +async function waitJobs ( + serversArg: PeerTubeServer[] | PeerTubeServer, + options: { + skipDelayed?: boolean // default false + runnerJobs?: boolean // default false + } = {} +) { + const { skipDelayed = false, runnerJobs = false } = options + + const pendingJobWait = process.env.NODE_PENDING_JOB_WAIT + ? parseInt(process.env.NODE_PENDING_JOB_WAIT, 10) + : 250 + + let servers: PeerTubeServer[] + + if (Array.isArray(serversArg) === false) servers = [ serversArg as PeerTubeServer ] + else servers = serversArg as PeerTubeServer[] + + const states: JobState[] = [ 'waiting', 'active' ] + if (!skipDelayed) states.push('delayed') + + const repeatableJobs: JobType[] = [ 'videos-views-stats', 'activitypub-cleaner' ] + let pendingRequests: boolean + + function tasksBuilder () { + const tasks: Promise[] = [] + + // Check if each server has pending request + for (const server of servers) { + if (process.env.DEBUG) console.log('Checking ' + server.url) + + for (const state of states) { + + const jobPromise = server.jobs.list({ + state, + start: 0, + count: 10, + sort: '-createdAt' + }).then(body => body.data) + .then(jobs => jobs.filter(j => !repeatableJobs.includes(j.type))) + .then(jobs => { + if (jobs.length !== 0) { + pendingRequests = true + + if (process.env.DEBUG) { + console.log(jobs) + } + } + }) + + tasks.push(jobPromise) + } + + const debugPromise = server.debug.getDebug() + .then(obj => { + if (obj.activityPubMessagesWaiting !== 0) { + pendingRequests = true + + if (process.env.DEBUG) { + console.log('AP messages waiting: ' + obj.activityPubMessagesWaiting) + } + } + }) + tasks.push(debugPromise) + + if (runnerJobs) { + const runnerJobsPromise = server.runnerJobs.list({ count: 100 }) + .then(({ data }) => { + for (const job of data) { + if (job.state.id !== RunnerJobState.COMPLETED) { + pendingRequests = true + + if (process.env.DEBUG) { + console.log(job) + } + } + } + }) + tasks.push(runnerJobsPromise) + } + } + + return tasks + } + + do { + pendingRequests = false + await Promise.all(tasksBuilder()) + + // Retry, in case of new jobs were created + if (pendingRequests === false) { + await wait(pendingJobWait) + await Promise.all(tasksBuilder()) + } + + if (pendingRequests) { + await wait(pendingJobWait) + } + } while (pendingRequests) +} + +async function expectNoFailedTranscodingJob (server: PeerTubeServer) { + const { data } = await server.jobs.listFailed({ jobType: 'video-transcoding' }) + expect(data).to.have.lengthOf(0) +} + +// --------------------------------------------------------------------------- + +export { + waitJobs, + expectNoFailedTranscodingJob +} diff --git a/packages/server-commands/src/server/metrics-command.ts b/packages/server-commands/src/server/metrics-command.ts new file mode 100644 index 000000000..1f969a024 --- /dev/null +++ b/packages/server-commands/src/server/metrics-command.ts @@ -0,0 +1,18 @@ +import { HttpStatusCode, PlaybackMetricCreate } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class MetricsCommand extends AbstractCommand { + + addPlaybackMetric (options: OverrideCommandOptions & { metrics: PlaybackMetricCreate }) { + const path = '/api/v1/metrics/playback' + + return this.postBodyRequest({ + ...options, + + path, + fields: options.metrics, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/object-storage-command.ts b/packages/server-commands/src/server/object-storage-command.ts new file mode 100644 index 000000000..ff8d5d75c --- /dev/null +++ b/packages/server-commands/src/server/object-storage-command.ts @@ -0,0 +1,165 @@ +import { randomInt } from 'crypto' +import { HttpStatusCode } from '@peertube/peertube-models' +import { makePostBodyRequest } from '../requests/index.js' + +export class ObjectStorageCommand { + static readonly DEFAULT_SCALEWAY_BUCKET = 'peertube-ci-test' + + private readonly bucketsCreated: string[] = [] + private readonly seed: number + + // --------------------------------------------------------------------------- + + constructor () { + this.seed = randomInt(0, 10000) + } + + static getMockCredentialsConfig () { + return { + access_key_id: 'AKIAIOSFODNN7EXAMPLE', + secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + } + } + + static getMockEndpointHost () { + return 'localhost:9444' + } + + static getMockRegion () { + return 'us-east-1' + } + + getDefaultMockConfig () { + return { + object_storage: { + enabled: true, + endpoint: 'http://' + ObjectStorageCommand.getMockEndpointHost(), + region: ObjectStorageCommand.getMockRegion(), + + credentials: ObjectStorageCommand.getMockCredentialsConfig(), + + streaming_playlists: { + bucket_name: this.getMockStreamingPlaylistsBucketName() + }, + + web_videos: { + bucket_name: this.getMockWebVideosBucketName() + } + } + } + } + + getMockWebVideosBaseUrl () { + return `http://${this.getMockWebVideosBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/` + } + + getMockPlaylistBaseUrl () { + return `http://${this.getMockStreamingPlaylistsBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/` + } + + async prepareDefaultMockBuckets () { + await this.createMockBucket(this.getMockStreamingPlaylistsBucketName()) + await this.createMockBucket(this.getMockWebVideosBucketName()) + } + + async createMockBucket (name: string) { + this.bucketsCreated.push(name) + + await this.deleteMockBucket(name) + + await makePostBodyRequest({ + url: ObjectStorageCommand.getMockEndpointHost(), + path: '/ui/' + name + '?create', + expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307 + }) + + await makePostBodyRequest({ + url: ObjectStorageCommand.getMockEndpointHost(), + path: '/ui/' + name + '?make-public', + expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307 + }) + } + + async cleanupMock () { + for (const name of this.bucketsCreated) { + await this.deleteMockBucket(name) + } + } + + getMockStreamingPlaylistsBucketName (name = 'streaming-playlists') { + return this.getMockBucketName(name) + } + + getMockWebVideosBucketName (name = 'web-videos') { + return this.getMockBucketName(name) + } + + getMockBucketName (name: string) { + return `${this.seed}-${name}` + } + + private async deleteMockBucket (name: string) { + await makePostBodyRequest({ + url: ObjectStorageCommand.getMockEndpointHost(), + path: '/ui/' + name + '?delete', + expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307 + }) + } + + // --------------------------------------------------------------------------- + + static getDefaultScalewayConfig (options: { + serverNumber: number + enablePrivateProxy?: boolean // default true + privateACL?: 'private' | 'public-read' // default 'private' + }) { + const { serverNumber, enablePrivateProxy = true, privateACL = 'private' } = options + + return { + object_storage: { + enabled: true, + endpoint: this.getScalewayEndpointHost(), + region: this.getScalewayRegion(), + + credentials: this.getScalewayCredentialsConfig(), + + upload_acl: { + private: privateACL + }, + + proxy: { + proxify_private_files: enablePrivateProxy + }, + + streaming_playlists: { + bucket_name: this.DEFAULT_SCALEWAY_BUCKET, + prefix: `test:server-${serverNumber}-streaming-playlists:` + }, + + web_videos: { + bucket_name: this.DEFAULT_SCALEWAY_BUCKET, + prefix: `test:server-${serverNumber}-web-videos:` + } + } + } + } + + static getScalewayCredentialsConfig () { + return { + access_key_id: process.env.OBJECT_STORAGE_SCALEWAY_KEY_ID, + secret_access_key: process.env.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY + } + } + + static getScalewayEndpointHost () { + return 's3.fr-par.scw.cloud' + } + + static getScalewayRegion () { + return 'fr-par' + } + + static getScalewayBaseUrl () { + return `https://${this.DEFAULT_SCALEWAY_BUCKET}.${this.getScalewayEndpointHost()}/` + } +} diff --git a/packages/server-commands/src/server/plugins-command.ts b/packages/server-commands/src/server/plugins-command.ts new file mode 100644 index 000000000..f85ef0330 --- /dev/null +++ b/packages/server-commands/src/server/plugins-command.ts @@ -0,0 +1,258 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { readJSON, writeJSON } from 'fs-extra/esm' +import { join } from 'path' +import { + HttpStatusCode, + HttpStatusCodeType, + PeerTubePlugin, + PeerTubePluginIndex, + PeertubePluginIndexList, + PluginPackageJSON, + PluginTranslation, + PluginType_Type, + PublicServerSetting, + RegisteredServerSettings, + ResultList +} from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class PluginsCommand extends AbstractCommand { + + static getPluginTestPath (suffix = '') { + return buildAbsoluteFixturePath('peertube-plugin-test' + suffix) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + pluginType?: PluginType_Type + uninstalled?: boolean + }) { + const { start, count, sort, pluginType, uninstalled } = options + const path = '/api/v1/plugins' + + return this.getRequestBody>({ + ...options, + + path, + query: { + start, + count, + sort, + pluginType, + uninstalled + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listAvailable (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + pluginType?: PluginType_Type + currentPeerTubeEngine?: string + search?: string + expectedStatus?: HttpStatusCodeType + }) { + const { start, count, sort, pluginType, search, currentPeerTubeEngine } = options + const path = '/api/v1/plugins/available' + + const query: PeertubePluginIndexList = { + start, + count, + sort, + pluginType, + currentPeerTubeEngine, + search + } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + npmName: string + }) { + const path = '/api/v1/plugins/' + options.npmName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateSettings (options: OverrideCommandOptions & { + npmName: string + settings: any + }) { + const { npmName, settings } = options + const path = '/api/v1/plugins/' + npmName + '/settings' + + return this.putBodyRequest({ + ...options, + + path, + fields: { settings }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getRegisteredSettings (options: OverrideCommandOptions & { + npmName: string + }) { + const path = '/api/v1/plugins/' + options.npmName + '/registered-settings' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPublicSettings (options: OverrideCommandOptions & { + npmName: string + }) { + const { npmName } = options + const path = '/api/v1/plugins/' + npmName + '/public-settings' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getTranslations (options: OverrideCommandOptions & { + locale: string + }) { + const { locale } = options + const path = '/plugins/translations/' + locale + '.json' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + install (options: OverrideCommandOptions & { + path?: string + npmName?: string + pluginVersion?: string + }) { + const { npmName, path, pluginVersion } = options + const apiPath = '/api/v1/plugins/install' + + return this.postBodyRequest({ + ...options, + + path: apiPath, + fields: { npmName, path, pluginVersion }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + update (options: OverrideCommandOptions & { + path?: string + npmName?: string + }) { + const { npmName, path } = options + const apiPath = '/api/v1/plugins/update' + + return this.postBodyRequest({ + ...options, + + path: apiPath, + fields: { npmName, path }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + uninstall (options: OverrideCommandOptions & { + npmName: string + }) { + const { npmName } = options + const apiPath = '/api/v1/plugins/uninstall' + + return this.postBodyRequest({ + ...options, + + path: apiPath, + fields: { npmName }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getCSS (options: OverrideCommandOptions = {}) { + const path = '/plugins/global.css' + + return this.getRequestText({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getExternalAuth (options: OverrideCommandOptions & { + npmName: string + npmVersion: string + authName: string + query?: any + }) { + const { npmName, npmVersion, authName, query } = options + + const path = '/plugins/' + npmName + '/' + npmVersion + '/auth/' + authName + + return this.getRequest({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200, + redirects: 0 + }) + } + + updatePackageJSON (npmName: string, json: any) { + const path = this.getPackageJSONPath(npmName) + + return writeJSON(path, json) + } + + getPackageJSON (npmName: string): Promise { + const path = this.getPackageJSONPath(npmName) + + return readJSON(path) + } + + private getPackageJSONPath (npmName: string) { + return this.server.servers.buildDirectory(join('plugins', 'node_modules', npmName, 'package.json')) + } +} diff --git a/packages/server-commands/src/server/redundancy-command.ts b/packages/server-commands/src/server/redundancy-command.ts new file mode 100644 index 000000000..a0ec3e80e --- /dev/null +++ b/packages/server-commands/src/server/redundancy-command.ts @@ -0,0 +1,80 @@ +import { HttpStatusCode, ResultList, VideoRedundanciesTarget, VideoRedundancy } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RedundancyCommand extends AbstractCommand { + + updateRedundancy (options: OverrideCommandOptions & { + host: string + redundancyAllowed: boolean + }) { + const { host, redundancyAllowed } = options + const path = '/api/v1/server/redundancy/' + host + + return this.putBodyRequest({ + ...options, + + path, + fields: { redundancyAllowed }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + listVideos (options: OverrideCommandOptions & { + target: VideoRedundanciesTarget + start?: number + count?: number + sort?: string + }) { + const path = '/api/v1/server/redundancy/videos' + + const { target, start, count, sort } = options + + return this.getRequestBody>({ + ...options, + + path, + + query: { + start: start ?? 0, + count: count ?? 5, + sort: sort ?? 'name', + target + }, + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + addVideo (options: OverrideCommandOptions & { + videoId: number + }) { + const path = '/api/v1/server/redundancy/videos' + const { videoId } = options + + return this.postBodyRequest({ + ...options, + + path, + fields: { videoId }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeVideo (options: OverrideCommandOptions & { + redundancyId: number + }) { + const { redundancyId } = options + const path = '/api/v1/server/redundancy/videos/' + redundancyId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/server/server.ts b/packages/server-commands/src/server/server.ts new file mode 100644 index 000000000..57a897c17 --- /dev/null +++ b/packages/server-commands/src/server/server.ts @@ -0,0 +1,451 @@ +import { ChildProcess, fork } from 'child_process' +import { copy } from 'fs-extra/esm' +import { join } from 'path' +import { randomInt } from '@peertube/peertube-core-utils' +import { Video, VideoChannel, VideoChannelSync, VideoCreateResult, VideoDetails } from '@peertube/peertube-models' +import { parallelTests, root } from '@peertube/peertube-node-utils' +import { BulkCommand } from '../bulk/index.js' +import { CLICommand } from '../cli/index.js' +import { CustomPagesCommand } from '../custom-pages/index.js' +import { FeedCommand } from '../feeds/index.js' +import { LogsCommand } from '../logs/index.js' +import { AbusesCommand } from '../moderation/index.js' +import { OverviewsCommand } from '../overviews/index.js' +import { RunnerJobsCommand, RunnerRegistrationTokensCommand, RunnersCommand } from '../runners/index.js' +import { SearchCommand } from '../search/index.js' +import { SocketIOCommand } from '../socket/index.js' +import { + AccountsCommand, + BlocklistCommand, + LoginCommand, + NotificationsCommand, + RegistrationsCommand, + SubscriptionsCommand, + TwoFactorCommand, + UsersCommand +} from '../users/index.js' +import { + BlacklistCommand, + CaptionsCommand, + ChangeOwnershipCommand, + ChannelsCommand, + ChannelSyncsCommand, + CommentsCommand, + HistoryCommand, + ImportsCommand, + LiveCommand, + PlaylistsCommand, + ServicesCommand, + StoryboardCommand, + StreamingPlaylistsCommand, + VideoPasswordsCommand, + VideosCommand, + VideoStatsCommand, + VideoStudioCommand, + VideoTokenCommand, + ViewsCommand +} from '../videos/index.js' +import { ConfigCommand } from './config-command.js' +import { ContactFormCommand } from './contact-form-command.js' +import { DebugCommand } from './debug-command.js' +import { FollowsCommand } from './follows-command.js' +import { JobsCommand } from './jobs-command.js' +import { MetricsCommand } from './metrics-command.js' +import { PluginsCommand } from './plugins-command.js' +import { RedundancyCommand } from './redundancy-command.js' +import { ServersCommand } from './servers-command.js' +import { StatsCommand } from './stats-command.js' + +export type RunServerOptions = { + hideLogs?: boolean + nodeArgs?: string[] + peertubeArgs?: string[] + env?: { [ id: string ]: string } +} + +export class PeerTubeServer { + app?: ChildProcess + + url: string + host?: string + hostname?: string + port?: number + + rtmpPort?: number + rtmpsPort?: number + + parallel?: boolean + internalServerNumber: number + + serverNumber?: number + customConfigFile?: string + + store?: { + client?: { + id?: string + secret?: string + } + + user?: { + username: string + password: string + email?: string + } + + channel?: VideoChannel + videoChannelSync?: Partial + + video?: Video + videoCreated?: VideoCreateResult + videoDetails?: VideoDetails + + videos?: { id: number, uuid: string }[] + } + + accessToken?: string + refreshToken?: string + + bulk?: BulkCommand + cli?: CLICommand + customPage?: CustomPagesCommand + feed?: FeedCommand + logs?: LogsCommand + abuses?: AbusesCommand + overviews?: OverviewsCommand + search?: SearchCommand + contactForm?: ContactFormCommand + debug?: DebugCommand + follows?: FollowsCommand + jobs?: JobsCommand + metrics?: MetricsCommand + plugins?: PluginsCommand + redundancy?: RedundancyCommand + stats?: StatsCommand + config?: ConfigCommand + socketIO?: SocketIOCommand + accounts?: AccountsCommand + blocklist?: BlocklistCommand + subscriptions?: SubscriptionsCommand + live?: LiveCommand + services?: ServicesCommand + blacklist?: BlacklistCommand + captions?: CaptionsCommand + changeOwnership?: ChangeOwnershipCommand + playlists?: PlaylistsCommand + history?: HistoryCommand + imports?: ImportsCommand + channelSyncs?: ChannelSyncsCommand + streamingPlaylists?: StreamingPlaylistsCommand + channels?: ChannelsCommand + comments?: CommentsCommand + notifications?: NotificationsCommand + servers?: ServersCommand + login?: LoginCommand + users?: UsersCommand + videoStudio?: VideoStudioCommand + videos?: VideosCommand + videoStats?: VideoStatsCommand + views?: ViewsCommand + twoFactor?: TwoFactorCommand + videoToken?: VideoTokenCommand + registrations?: RegistrationsCommand + videoPasswords?: VideoPasswordsCommand + + storyboard?: StoryboardCommand + + runners?: RunnersCommand + runnerRegistrationTokens?: RunnerRegistrationTokensCommand + runnerJobs?: RunnerJobsCommand + + constructor (options: { serverNumber: number } | { url: string }) { + if ((options as any).url) { + this.setUrl((options as any).url) + } else { + this.setServerNumber((options as any).serverNumber) + } + + this.store = { + client: { + id: null, + secret: null + }, + user: { + username: null, + password: null + } + } + + this.assignCommands() + } + + setServerNumber (serverNumber: number) { + this.serverNumber = serverNumber + + this.parallel = parallelTests() + + this.internalServerNumber = this.parallel ? this.randomServer() : this.serverNumber + this.rtmpPort = this.parallel ? this.randomRTMP() : 1936 + this.rtmpsPort = this.parallel ? this.randomRTMP() : 1937 + this.port = 9000 + this.internalServerNumber + + this.url = `http://127.0.0.1:${this.port}` + this.host = `127.0.0.1:${this.port}` + this.hostname = '127.0.0.1' + } + + setUrl (url: string) { + const parsed = new URL(url) + + this.url = url + this.host = parsed.host + this.hostname = parsed.hostname + this.port = parseInt(parsed.port) + } + + getDirectoryPath (directoryName: string) { + const testDirectory = 'test' + this.internalServerNumber + + return join(root(), testDirectory, directoryName) + } + + async flushAndRun (configOverride?: object, options: RunServerOptions = {}) { + await ServersCommand.flushTests(this.internalServerNumber) + + return this.run(configOverride, options) + } + + async run (configOverrideArg?: any, options: RunServerOptions = {}) { + // These actions are async so we need to be sure that they have both been done + const serverRunString = { + 'HTTP server listening': false + } + const key = 'Database peertube_test' + this.internalServerNumber + ' is ready' + serverRunString[key] = false + + const regexps = { + client_id: 'Client id: (.+)', + client_secret: 'Client secret: (.+)', + user_username: 'Username: (.+)', + user_password: 'User password: (.+)' + } + + await this.assignCustomConfigFile() + + const configOverride = this.buildConfigOverride() + + if (configOverrideArg !== undefined) { + Object.assign(configOverride, configOverrideArg) + } + + // Share the environment + const env = { ...process.env } + env['NODE_ENV'] = 'test' + env['NODE_APP_INSTANCE'] = this.internalServerNumber.toString() + env['NODE_CONFIG'] = JSON.stringify(configOverride) + + if (options.env) { + Object.assign(env, options.env) + } + + const execArgv = options.nodeArgs || [] + // FIXME: too slow :/ + // execArgv.push('--enable-source-maps') + + const forkOptions = { + silent: true, + env, + detached: false, + execArgv + } + + const peertubeArgs = options.peertubeArgs || [] + + return new Promise((res, rej) => { + const self = this + let aggregatedLogs = '' + + this.app = fork(join(root(), 'dist', 'server.js'), peertubeArgs, forkOptions) + + const onPeerTubeExit = () => rej(new Error('Process exited:\n' + aggregatedLogs)) + const onParentExit = () => { + if (!this.app?.pid) return + + try { + process.kill(self.app.pid) + } catch { /* empty */ } + } + + this.app.on('exit', onPeerTubeExit) + process.on('exit', onParentExit) + + this.app.stdout.on('data', function onStdout (data) { + let dontContinue = false + + const log: string = data.toString() + aggregatedLogs += log + + // Capture things if we want to + for (const key of Object.keys(regexps)) { + const regexp = regexps[key] + const matches = log.match(regexp) + if (matches !== null) { + if (key === 'client_id') self.store.client.id = matches[1] + else if (key === 'client_secret') self.store.client.secret = matches[1] + else if (key === 'user_username') self.store.user.username = matches[1] + else if (key === 'user_password') self.store.user.password = matches[1] + } + } + + // Check if all required sentences are here + for (const key of Object.keys(serverRunString)) { + if (log.includes(key)) serverRunString[key] = true + if (serverRunString[key] === false) dontContinue = true + } + + // If no, there is maybe one thing not already initialized (client/user credentials generation...) + if (dontContinue === true) return + + if (options.hideLogs === false) { + console.log(log) + } else { + process.removeListener('exit', onParentExit) + self.app.stdout.removeListener('data', onStdout) + self.app.removeListener('exit', onPeerTubeExit) + } + + res() + }) + }) + } + + kill () { + if (!this.app) return Promise.resolve() + + process.kill(this.app.pid) + + this.app = null + + return Promise.resolve() + } + + private randomServer () { + const low = 2500 + const high = 10000 + + return randomInt(low, high) + } + + private randomRTMP () { + const low = 1900 + const high = 2100 + + return randomInt(low, high) + } + + private async assignCustomConfigFile () { + if (this.internalServerNumber === this.serverNumber) return + + const basePath = join(root(), 'config') + + const tmpConfigFile = join(basePath, `test-${this.internalServerNumber}.yaml`) + await copy(join(basePath, `test-${this.serverNumber}.yaml`), tmpConfigFile) + + this.customConfigFile = tmpConfigFile + } + + private buildConfigOverride () { + if (!this.parallel) return {} + + return { + listen: { + port: this.port + }, + webserver: { + port: this.port + }, + database: { + suffix: '_test' + this.internalServerNumber + }, + storage: { + tmp: this.getDirectoryPath('tmp') + '/', + tmp_persistent: this.getDirectoryPath('tmp-persistent') + '/', + bin: this.getDirectoryPath('bin') + '/', + avatars: this.getDirectoryPath('avatars') + '/', + web_videos: this.getDirectoryPath('web-videos') + '/', + streaming_playlists: this.getDirectoryPath('streaming-playlists') + '/', + redundancy: this.getDirectoryPath('redundancy') + '/', + logs: this.getDirectoryPath('logs') + '/', + previews: this.getDirectoryPath('previews') + '/', + thumbnails: this.getDirectoryPath('thumbnails') + '/', + storyboards: this.getDirectoryPath('storyboards') + '/', + torrents: this.getDirectoryPath('torrents') + '/', + captions: this.getDirectoryPath('captions') + '/', + cache: this.getDirectoryPath('cache') + '/', + plugins: this.getDirectoryPath('plugins') + '/', + well_known: this.getDirectoryPath('well-known') + '/' + }, + admin: { + email: `admin${this.internalServerNumber}@example.com` + }, + live: { + rtmp: { + port: this.rtmpPort + } + } + } + } + + private assignCommands () { + this.bulk = new BulkCommand(this) + this.cli = new CLICommand(this) + this.customPage = new CustomPagesCommand(this) + this.feed = new FeedCommand(this) + this.logs = new LogsCommand(this) + this.abuses = new AbusesCommand(this) + this.overviews = new OverviewsCommand(this) + this.search = new SearchCommand(this) + this.contactForm = new ContactFormCommand(this) + this.debug = new DebugCommand(this) + this.follows = new FollowsCommand(this) + this.jobs = new JobsCommand(this) + this.metrics = new MetricsCommand(this) + this.plugins = new PluginsCommand(this) + this.redundancy = new RedundancyCommand(this) + this.stats = new StatsCommand(this) + this.config = new ConfigCommand(this) + this.socketIO = new SocketIOCommand(this) + this.accounts = new AccountsCommand(this) + this.blocklist = new BlocklistCommand(this) + this.subscriptions = new SubscriptionsCommand(this) + this.live = new LiveCommand(this) + this.services = new ServicesCommand(this) + this.blacklist = new BlacklistCommand(this) + this.captions = new CaptionsCommand(this) + this.changeOwnership = new ChangeOwnershipCommand(this) + this.playlists = new PlaylistsCommand(this) + this.history = new HistoryCommand(this) + this.imports = new ImportsCommand(this) + this.channelSyncs = new ChannelSyncsCommand(this) + this.streamingPlaylists = new StreamingPlaylistsCommand(this) + this.channels = new ChannelsCommand(this) + this.comments = new CommentsCommand(this) + this.notifications = new NotificationsCommand(this) + this.servers = new ServersCommand(this) + this.login = new LoginCommand(this) + this.users = new UsersCommand(this) + this.videos = new VideosCommand(this) + this.videoStudio = new VideoStudioCommand(this) + this.videoStats = new VideoStatsCommand(this) + this.views = new ViewsCommand(this) + this.twoFactor = new TwoFactorCommand(this) + this.videoToken = new VideoTokenCommand(this) + this.registrations = new RegistrationsCommand(this) + + this.storyboard = new StoryboardCommand(this) + + this.runners = new RunnersCommand(this) + this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) + this.runnerJobs = new RunnerJobsCommand(this) + this.videoPasswords = new VideoPasswordsCommand(this) + } +} diff --git a/packages/server-commands/src/server/servers-command.ts b/packages/server-commands/src/server/servers-command.ts new file mode 100644 index 000000000..0b722b62f --- /dev/null +++ b/packages/server-commands/src/server/servers-command.ts @@ -0,0 +1,104 @@ +import { exec } from 'child_process' +import { copy, ensureDir, remove } from 'fs-extra/esm' +import { readdir, readFile } from 'fs/promises' +import { basename, join } from 'path' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { getFileSize, isGithubCI, root } from '@peertube/peertube-node-utils' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ServersCommand extends AbstractCommand { + + static flushTests (internalServerNumber: number) { + return new Promise((res, rej) => { + const suffix = ` -- ${internalServerNumber}` + + return exec('npm run clean:server:test' + suffix, (err, _stdout, stderr) => { + if (err || stderr) return rej(err || new Error(stderr)) + + return res() + }) + }) + } + + ping (options: OverrideCommandOptions = {}) { + return this.getRequestBody({ + ...options, + + path: '/api/v1/ping', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + cleanupTests () { + const promises: Promise[] = [] + + const saveGithubLogsIfNeeded = async () => { + if (!isGithubCI()) return + + await ensureDir('artifacts') + + const origin = this.buildDirectory('logs/peertube.log') + const destname = `peertube-${this.server.internalServerNumber}.log` + console.log('Saving logs %s.', destname) + + await copy(origin, join('artifacts', destname)) + } + + if (this.server.parallel) { + const promise = saveGithubLogsIfNeeded() + .then(() => ServersCommand.flushTests(this.server.internalServerNumber)) + + promises.push(promise) + } + + if (this.server.customConfigFile) { + promises.push(remove(this.server.customConfigFile)) + } + + return promises + } + + async waitUntilLog (str: string, count = 1, strictCount = true) { + const logfile = this.buildDirectory('logs/peertube.log') + + while (true) { + const buf = await readFile(logfile) + + const matches = buf.toString().match(new RegExp(str, 'g')) + if (matches && matches.length === count) return + if (matches && strictCount === false && matches.length >= count) return + + await wait(1000) + } + } + + buildDirectory (directory: string) { + return join(root(), 'test' + this.server.internalServerNumber, directory) + } + + async countFiles (directory: string) { + const files = await readdir(this.buildDirectory(directory)) + + return files.length + } + + buildWebVideoFilePath (fileUrl: string) { + return this.buildDirectory(join('web-videos', basename(fileUrl))) + } + + buildFragmentedFilePath (videoUUID: string, fileUrl: string) { + return this.buildDirectory(join('streaming-playlists', 'hls', videoUUID, basename(fileUrl))) + } + + getLogContent () { + return readFile(this.buildDirectory('logs/peertube.log')) + } + + async getServerFileSize (subPath: string) { + const path = this.server.servers.buildDirectory(subPath) + + return getFileSize(path) + } +} diff --git a/packages/server-commands/src/server/servers.ts b/packages/server-commands/src/server/servers.ts new file mode 100644 index 000000000..caf9866e1 --- /dev/null +++ b/packages/server-commands/src/server/servers.ts @@ -0,0 +1,68 @@ +import { ensureDir } from 'fs-extra/esm' +import { isGithubCI } from '@peertube/peertube-node-utils' +import { PeerTubeServer, RunServerOptions } from './server.js' + +async function createSingleServer (serverNumber: number, configOverride?: object, options: RunServerOptions = {}) { + const server = new PeerTubeServer({ serverNumber }) + + await server.flushAndRun(configOverride, options) + + return server +} + +function createMultipleServers (totalServers: number, configOverride?: object, options: RunServerOptions = {}) { + const serverPromises: Promise[] = [] + + for (let i = 1; i <= totalServers; i++) { + serverPromises.push(createSingleServer(i, configOverride, options)) + } + + return Promise.all(serverPromises) +} + +function killallServers (servers: PeerTubeServer[]) { + return Promise.all(servers.map(s => s.kill())) +} + +async function cleanupTests (servers: PeerTubeServer[]) { + await killallServers(servers) + + if (isGithubCI()) { + await ensureDir('artifacts') + } + + let p: Promise[] = [] + for (const server of servers) { + p = p.concat(server.servers.cleanupTests()) + } + + return Promise.all(p) +} + +function getServerImportConfig (mode: 'youtube-dl' | 'yt-dlp') { + return { + import: { + videos: { + http: { + youtube_dl_release: { + url: mode === 'youtube-dl' + ? 'https://yt-dl.org/downloads/latest/youtube-dl' + : 'https://api.github.com/repos/yt-dlp/yt-dlp/releases', + + name: mode + } + } + } + } + } +} + +// --------------------------------------------------------------------------- + +export { + createSingleServer, + createMultipleServers, + cleanupTests, + killallServers, + getServerImportConfig +} diff --git a/packages/server-commands/src/server/stats-command.ts b/packages/server-commands/src/server/stats-command.ts new file mode 100644 index 000000000..80acd7bdc --- /dev/null +++ b/packages/server-commands/src/server/stats-command.ts @@ -0,0 +1,25 @@ +import { HttpStatusCode, ServerStats } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class StatsCommand extends AbstractCommand { + + get (options: OverrideCommandOptions & { + useCache?: boolean // default false + } = {}) { + const { useCache = false } = options + const path = '/api/v1/server/stats' + + const query = { + t: useCache ? undefined : new Date().getTime() + } + + return this.getRequestBody({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/shared/abstract-command.ts b/packages/server-commands/src/shared/abstract-command.ts new file mode 100644 index 000000000..bb6522e07 --- /dev/null +++ b/packages/server-commands/src/shared/abstract-command.ts @@ -0,0 +1,225 @@ +import { isAbsolute } from 'path' +import { HttpStatusCodeType } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + makePutBodyRequest, + makeUploadRequest, + unwrapBody, + unwrapText +} from '../requests/requests.js' + +import type { PeerTubeServer } from '../server/server.js' + +export interface OverrideCommandOptions { + token?: string + expectedStatus?: HttpStatusCodeType +} + +interface InternalCommonCommandOptions extends OverrideCommandOptions { + // Default to server.url + url?: string + + path: string + // If we automatically send the server token if the token is not provided + implicitToken: boolean + defaultExpectedStatus: HttpStatusCodeType + + // Common optional request parameters + contentType?: string + accept?: string + redirects?: number + range?: string + host?: string + headers?: { [ name: string ]: string } + requestType?: string + responseType?: string + xForwardedFor?: string +} + +interface InternalGetCommandOptions extends InternalCommonCommandOptions { + query?: { [ id: string ]: any } +} + +interface InternalDeleteCommandOptions extends InternalCommonCommandOptions { + query?: { [ id: string ]: any } + rawQuery?: string +} + +abstract class AbstractCommand { + + constructor ( + protected server: PeerTubeServer + ) { + + } + + protected getRequestBody (options: InternalGetCommandOptions) { + return unwrapBody(this.getRequest(options)) + } + + protected getRequestText (options: InternalGetCommandOptions) { + return unwrapText(this.getRequest(options)) + } + + protected getRawRequest (options: Omit) { + const { url, range } = options + const { host, protocol, pathname } = new URL(url) + + return this.getRequest({ + ...options, + + token: this.buildCommonRequestToken(options), + defaultExpectedStatus: this.buildExpectedStatus(options), + + url: `${protocol}//${host}`, + path: pathname, + range + }) + } + + protected getRequest (options: InternalGetCommandOptions) { + const { query } = options + + return makeGetRequest({ + ...this.buildCommonRequestOptions(options), + + query + }) + } + + protected deleteRequest (options: InternalDeleteCommandOptions) { + const { query, rawQuery } = options + + return makeDeleteRequest({ + ...this.buildCommonRequestOptions(options), + + query, + rawQuery + }) + } + + protected putBodyRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + headers?: { [name: string]: string } + }) { + const { fields, headers } = options + + return makePutBodyRequest({ + ...this.buildCommonRequestOptions(options), + + fields, + headers + }) + } + + protected postBodyRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + headers?: { [name: string]: string } + }) { + const { fields, headers } = options + + return makePostBodyRequest({ + ...this.buildCommonRequestOptions(options), + + fields, + headers + }) + } + + protected postUploadRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + attaches?: { [ fieldName: string ]: any } + }) { + const { fields, attaches } = options + + return makeUploadRequest({ + ...this.buildCommonRequestOptions(options), + + method: 'POST', + fields, + attaches + }) + } + + protected putUploadRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + attaches?: { [ fieldName: string ]: any } + }) { + const { fields, attaches } = options + + return makeUploadRequest({ + ...this.buildCommonRequestOptions(options), + + method: 'PUT', + fields, + attaches + }) + } + + protected updateImageRequest (options: InternalCommonCommandOptions & { + fixture: string + fieldname: string + }) { + const filePath = isAbsolute(options.fixture) + ? options.fixture + : buildAbsoluteFixturePath(options.fixture) + + return this.postUploadRequest({ + ...options, + + fields: {}, + attaches: { [options.fieldname]: filePath } + }) + } + + protected buildCommonRequestOptions (options: InternalCommonCommandOptions) { + const { url, path, redirects, contentType, accept, range, host, headers, requestType, xForwardedFor, responseType } = options + + return { + url: url ?? this.server.url, + path, + + token: this.buildCommonRequestToken(options), + expectedStatus: this.buildExpectedStatus(options), + + redirects, + contentType, + range, + host, + accept, + headers, + type: requestType, + responseType, + xForwardedFor + } + } + + protected buildCommonRequestToken (options: Pick) { + const { token } = options + + const fallbackToken = options.implicitToken + ? this.server.accessToken + : undefined + + return token !== undefined ? token : fallbackToken + } + + protected buildExpectedStatus (options: Pick) { + const { expectedStatus, defaultExpectedStatus } = options + + return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus + } + + protected buildVideoPasswordHeader (videoPassword: string) { + return videoPassword !== undefined && videoPassword !== null + ? { 'x-peertube-video-password': videoPassword } + : undefined + } +} + +export { + AbstractCommand +} diff --git a/packages/server-commands/src/shared/index.ts b/packages/server-commands/src/shared/index.ts new file mode 100644 index 000000000..795db3d55 --- /dev/null +++ b/packages/server-commands/src/shared/index.ts @@ -0,0 +1 @@ +export * from './abstract-command.js' diff --git a/packages/server-commands/src/socket/index.ts b/packages/server-commands/src/socket/index.ts new file mode 100644 index 000000000..24b8f4b46 --- /dev/null +++ b/packages/server-commands/src/socket/index.ts @@ -0,0 +1 @@ +export * from './socket-io-command.js' diff --git a/packages/server-commands/src/socket/socket-io-command.ts b/packages/server-commands/src/socket/socket-io-command.ts new file mode 100644 index 000000000..9c18c2a1f --- /dev/null +++ b/packages/server-commands/src/socket/socket-io-command.ts @@ -0,0 +1,24 @@ +import { io } from 'socket.io-client' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class SocketIOCommand extends AbstractCommand { + + getUserNotificationSocket (options: OverrideCommandOptions = {}) { + return io(this.server.url + '/user-notifications', { + query: { accessToken: options.token ?? this.server.accessToken } + }) + } + + getLiveNotificationSocket () { + return io(this.server.url + '/live-videos') + } + + getRunnersSocket (options: { + runnerToken: string + }) { + return io(this.server.url + '/runners', { + reconnection: false, + auth: { runnerToken: options.runnerToken } + }) + } +} diff --git a/packages/server-commands/src/users/accounts-command.ts b/packages/server-commands/src/users/accounts-command.ts new file mode 100644 index 000000000..fd98b7eea --- /dev/null +++ b/packages/server-commands/src/users/accounts-command.ts @@ -0,0 +1,76 @@ +import { Account, AccountVideoRate, ActorFollow, HttpStatusCode, ResultList, VideoRateType } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class AccountsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + sort?: string // default -createdAt + } = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/accounts' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + accountName: string + }) { + const path = '/api/v1/accounts/' + options.accountName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listRatings (options: OverrideCommandOptions & { + accountName: string + rating?: VideoRateType + }) { + const { rating, accountName } = options + const path = '/api/v1/accounts/' + accountName + '/ratings' + + const query = { rating } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listFollowers (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + search?: string + }) { + const { accountName, start, count, sort, search } = options + const path = '/api/v1/accounts/' + accountName + '/followers' + + const query = { start, count, sort, search } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/users/accounts.ts b/packages/server-commands/src/users/accounts.ts new file mode 100644 index 000000000..3b8b9d36a --- /dev/null +++ b/packages/server-commands/src/users/accounts.ts @@ -0,0 +1,15 @@ +import { PeerTubeServer } from '../server/server.js' + +async function setDefaultAccountAvatar (serversArg: PeerTubeServer | PeerTubeServer[], token?: string) { + const servers = Array.isArray(serversArg) + ? serversArg + : [ serversArg ] + + for (const server of servers) { + await server.users.updateMyAvatar({ fixture: 'avatar.png', token }) + } +} + +export { + setDefaultAccountAvatar +} diff --git a/packages/server-commands/src/users/blocklist-command.ts b/packages/server-commands/src/users/blocklist-command.ts new file mode 100644 index 000000000..c77c56131 --- /dev/null +++ b/packages/server-commands/src/users/blocklist-command.ts @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { AccountBlock, BlockStatus, HttpStatusCode, ResultList, ServerBlock } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +type ListBlocklistOptions = OverrideCommandOptions & { + start: number + count: number + + sort?: string // default -createdAt + + search?: string +} + +export class BlocklistCommand extends AbstractCommand { + + listMyAccountBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/users/me/blocklist/accounts' + + return this.listBlocklist(options, path) + } + + listMyServerBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/users/me/blocklist/servers' + + return this.listBlocklist(options, path) + } + + listServerAccountBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/server/blocklist/accounts' + + return this.listBlocklist(options, path) + } + + listServerServerBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/server/blocklist/servers' + + return this.listBlocklist(options, path) + } + + // --------------------------------------------------------------------------- + + getStatus (options: OverrideCommandOptions & { + accounts?: string[] + hosts?: string[] + }) { + const { accounts, hosts } = options + + const path = '/api/v1/blocklist/status' + + return this.getRequestBody({ + ...options, + + path, + query: { + accounts, + hosts + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + addToMyBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/users/me/blocklist/accounts' + : '/api/v1/users/me/blocklist/servers' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + accountName: account, + host: server + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + addToServerBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/server/blocklist/accounts' + : '/api/v1/server/blocklist/servers' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + accountName: account, + host: server + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + removeFromMyBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/users/me/blocklist/accounts/' + account + : '/api/v1/users/me/blocklist/servers/' + server + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeFromServerBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/server/blocklist/accounts/' + account + : '/api/v1/server/blocklist/servers/' + server + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + private listBlocklist (options: ListBlocklistOptions, path: string) { + const { start, count, search, sort = '-createdAt' } = options + + return this.getRequestBody>({ + ...options, + + path, + query: { start, count, sort, search }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/packages/server-commands/src/users/index.ts b/packages/server-commands/src/users/index.ts new file mode 100644 index 000000000..baa048a43 --- /dev/null +++ b/packages/server-commands/src/users/index.ts @@ -0,0 +1,10 @@ +export * from './accounts-command.js' +export * from './accounts.js' +export * from './blocklist-command.js' +export * from './login.js' +export * from './login-command.js' +export * from './notifications-command.js' +export * from './registrations-command.js' +export * from './subscriptions-command.js' +export * from './two-factor-command.js' +export * from './users-command.js' diff --git a/packages/server-commands/src/users/login-command.ts b/packages/server-commands/src/users/login-command.ts new file mode 100644 index 000000000..92d123dfc --- /dev/null +++ b/packages/server-commands/src/users/login-command.ts @@ -0,0 +1,159 @@ +import { HttpStatusCode, PeerTubeProblemDocument } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +type LoginOptions = OverrideCommandOptions & { + client?: { id?: string, secret?: string } + user?: { username: string, password?: string } + otpToken?: string +} + +export class LoginCommand extends AbstractCommand { + + async login (options: LoginOptions = {}) { + const res = await this._login(options) + + return this.unwrapLoginBody(res.body) + } + + async loginAndGetResponse (options: LoginOptions = {}) { + const res = await this._login(options) + + return { + res, + body: this.unwrapLoginBody(res.body) + } + } + + getAccessToken (arg1?: { username: string, password?: string }): Promise + getAccessToken (arg1: string, password?: string): Promise + async getAccessToken (arg1?: { username: string, password?: string } | string, password?: string) { + let user: { username: string, password?: string } + + if (!arg1) user = this.server.store.user + else if (typeof arg1 === 'object') user = arg1 + else user = { username: arg1, password } + + try { + const body = await this.login({ user }) + + return body.access_token + } catch (err) { + throw new Error(`Cannot authenticate. Please check your username/password. (${err})`) + } + } + + loginUsingExternalToken (options: OverrideCommandOptions & { + username: string + externalAuthToken: string + }) { + const { username, externalAuthToken } = options + const path = '/api/v1/users/token' + + const body = { + client_id: this.server.store.client.id, + client_secret: this.server.store.client.secret, + username, + response_type: 'code', + grant_type: 'password', + scope: 'upload', + externalAuthToken + } + + return this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + logout (options: OverrideCommandOptions & { + token: string + }) { + const path = '/api/v1/users/revoke-token' + + return unwrapBody<{ redirectUrl: string }>(this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + refreshToken (options: OverrideCommandOptions & { + refreshToken: string + }) { + const path = '/api/v1/users/token' + + const body = { + client_id: this.server.store.client.id, + client_secret: this.server.store.client.secret, + refresh_token: options.refreshToken, + response_type: 'code', + grant_type: 'refresh_token' + } + + return this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getClient (options: OverrideCommandOptions = {}) { + const path = '/api/v1/oauth-clients/local' + + return this.getRequestBody<{ client_id: string, client_secret: string }>({ + ...options, + + path, + host: this.server.host, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private _login (options: LoginOptions) { + const { client = this.server.store.client, user = this.server.store.user, otpToken } = options + const path = '/api/v1/users/token' + + const body = { + client_id: client.id, + client_secret: client.secret, + username: user.username, + password: user.password ?? 'password', + response_type: 'code', + grant_type: 'password', + scope: 'upload' + } + + const headers = otpToken + ? { 'x-peertube-otp': otpToken } + : {} + + return this.postBodyRequest({ + ...options, + + path, + headers, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private unwrapLoginBody (body: any) { + return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument + } +} diff --git a/packages/server-commands/src/users/login.ts b/packages/server-commands/src/users/login.ts new file mode 100644 index 000000000..c48c42c72 --- /dev/null +++ b/packages/server-commands/src/users/login.ts @@ -0,0 +1,19 @@ +import { PeerTubeServer } from '../server/server.js' + +function setAccessTokensToServers (servers: PeerTubeServer[]) { + const tasks: Promise[] = [] + + for (const server of servers) { + const p = server.login.getAccessToken() + .then(t => { server.accessToken = t }) + tasks.push(p) + } + + return Promise.all(tasks) +} + +// --------------------------------------------------------------------------- + +export { + setAccessTokensToServers +} diff --git a/packages/server-commands/src/users/notifications-command.ts b/packages/server-commands/src/users/notifications-command.ts new file mode 100644 index 000000000..d90d56900 --- /dev/null +++ b/packages/server-commands/src/users/notifications-command.ts @@ -0,0 +1,85 @@ +import { HttpStatusCode, ResultList, UserNotification, UserNotificationSetting } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class NotificationsCommand extends AbstractCommand { + + updateMySettings (options: OverrideCommandOptions & { + settings: UserNotificationSetting + }) { + const path = '/api/v1/users/me/notification-settings' + + return this.putBodyRequest({ + ...options, + + path, + fields: options.settings, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + unread?: boolean + sort?: string + }) { + const { start, count, unread, sort = '-createdAt' } = options + const path = '/api/v1/users/me/notifications' + + return this.getRequestBody>({ + ...options, + + path, + query: { + start, + count, + sort, + unread + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + markAsRead (options: OverrideCommandOptions & { + ids: number[] + }) { + const { ids } = options + const path = '/api/v1/users/me/notifications/read' + + return this.postBodyRequest({ + ...options, + + path, + fields: { ids }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + markAsReadAll (options: OverrideCommandOptions) { + const path = '/api/v1/users/me/notifications/read-all' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async getLatest (options: OverrideCommandOptions = {}) { + const { total, data } = await this.list({ + ...options, + start: 0, + count: 1, + sort: '-createdAt' + }) + + if (total === 0) return undefined + + return data[0] + } +} diff --git a/packages/server-commands/src/users/registrations-command.ts b/packages/server-commands/src/users/registrations-command.ts new file mode 100644 index 000000000..2111fbd39 --- /dev/null +++ b/packages/server-commands/src/users/registrations-command.ts @@ -0,0 +1,157 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + ResultList, + UserRegistration, + UserRegistrationRequest, + UserRegistrationUpdateState +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RegistrationsCommand extends AbstractCommand { + + register (options: OverrideCommandOptions & Partial & Pick) { + const { password = 'password', email = options.username + '@example.com' } = options + const path = '/api/v1/users/register' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + ...pick(options, [ 'username', 'displayName', 'channel' ]), + + password, + email + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + requestRegistration ( + options: OverrideCommandOptions & Partial & Pick + ) { + const { password = 'password', email = options.username + '@example.com' } = options + const path = '/api/v1/users/registrations/request' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: { + ...pick(options, [ 'username', 'displayName', 'channel', 'registrationReason' ]), + + password, + email + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + // --------------------------------------------------------------------------- + + accept (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) { + const { id } = options + const path = '/api/v1/users/registrations/' + id + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + reject (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) { + const { id } = options + const path = '/api/v1/users/registrations/' + id + '/reject' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + delete (options: OverrideCommandOptions & { + id: number + }) { + const { id } = options + const path = '/api/v1/users/registrations/' + id + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + } = {}) { + const path = '/api/v1/users/registrations' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + askSendVerifyEmail (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/registrations/ask-send-verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + verifyEmail (options: OverrideCommandOptions & { + registrationId: number + verificationString: string + }) { + const { registrationId, verificationString } = options + const path = '/api/v1/users/registrations/' + registrationId + '/verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + verificationString + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/users/subscriptions-command.ts b/packages/server-commands/src/users/subscriptions-command.ts new file mode 100644 index 000000000..52a1f0e51 --- /dev/null +++ b/packages/server-commands/src/users/subscriptions-command.ts @@ -0,0 +1,83 @@ +import { HttpStatusCode, ResultList, VideoChannel } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class SubscriptionsCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + targetUri: string + }) { + const path = '/api/v1/users/me/subscriptions' + + return this.postBodyRequest({ + ...options, + + path, + fields: { uri: options.targetUri }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + sort?: string // default -createdAt + search?: string + } = {}) { + const { sort = '-createdAt', search } = options + const path = '/api/v1/users/me/subscriptions' + + return this.getRequestBody>({ + ...options, + + path, + query: { + sort, + search + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + uri: string + }) { + const path = '/api/v1/users/me/subscriptions/' + options.uri + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + remove (options: OverrideCommandOptions & { + uri: string + }) { + const path = '/api/v1/users/me/subscriptions/' + options.uri + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + exist (options: OverrideCommandOptions & { + uris: string[] + }) { + const path = '/api/v1/users/me/subscriptions/exist' + + return this.getRequestBody<{ [id: string ]: boolean }>({ + ...options, + + path, + query: { 'uris[]': options.uris }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/users/two-factor-command.ts b/packages/server-commands/src/users/two-factor-command.ts new file mode 100644 index 000000000..cf3d6cb68 --- /dev/null +++ b/packages/server-commands/src/users/two-factor-command.ts @@ -0,0 +1,92 @@ +import { TOTP } from 'otpauth' +import { HttpStatusCode, TwoFactorEnableResult } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class TwoFactorCommand extends AbstractCommand { + + static buildOTP (options: { + secret: string + }) { + const { secret } = options + + return new TOTP({ + issuer: 'PeerTube', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret + }) + } + + request (options: OverrideCommandOptions & { + userId: number + currentPassword?: string + }) { + const { currentPassword, userId } = options + + const path = '/api/v1/users/' + userId + '/two-factor/request' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: { currentPassword }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + confirmRequest (options: OverrideCommandOptions & { + userId: number + requestToken: string + otpToken: string + }) { + const { userId, requestToken, otpToken } = options + + const path = '/api/v1/users/' + userId + '/two-factor/confirm-request' + + return this.postBodyRequest({ + ...options, + + path, + fields: { requestToken, otpToken }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + disable (options: OverrideCommandOptions & { + userId: number + currentPassword?: string + }) { + const { userId, currentPassword } = options + const path = '/api/v1/users/' + userId + '/two-factor/disable' + + return this.postBodyRequest({ + ...options, + + path, + fields: { currentPassword }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async requestAndConfirm (options: OverrideCommandOptions & { + userId: number + currentPassword?: string + }) { + const { userId, currentPassword } = options + + const { otpRequest } = await this.request({ userId, currentPassword }) + + await this.confirmRequest({ + userId, + requestToken: otpRequest.requestToken, + otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate() + }) + + return otpRequest + } +} diff --git a/packages/server-commands/src/users/users-command.ts b/packages/server-commands/src/users/users-command.ts new file mode 100644 index 000000000..d3b11939e --- /dev/null +++ b/packages/server-commands/src/users/users-command.ts @@ -0,0 +1,389 @@ +import { omit, pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + MyUser, + ResultList, + ScopedToken, + User, + UserAdminFlagType, + UserCreateResult, + UserRole, + UserRoleType, + UserUpdate, + UserUpdateMe, + UserVideoQuota, + UserVideoRate +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class UsersCommand extends AbstractCommand { + + askResetPassword (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/ask-reset-password' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + resetPassword (options: OverrideCommandOptions & { + userId: number + verificationString: string + password: string + }) { + const { userId, verificationString, password } = options + const path = '/api/v1/users/' + userId + '/reset-password' + + return this.postBodyRequest({ + ...options, + + path, + fields: { password, verificationString }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + askSendVerifyEmail (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/ask-send-verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + verifyEmail (options: OverrideCommandOptions & { + userId: number + verificationString: string + isPendingEmail?: boolean // default false + }) { + const { userId, verificationString, isPendingEmail = false } = options + const path = '/api/v1/users/' + userId + '/verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + verificationString, + isPendingEmail + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + banUser (options: OverrideCommandOptions & { + userId: number + reason?: string + }) { + const { userId, reason } = options + const path = '/api/v1/users' + '/' + userId + '/block' + + return this.postBodyRequest({ + ...options, + + path, + fields: { reason }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + unbanUser (options: OverrideCommandOptions & { + userId: number + }) { + const { userId } = options + const path = '/api/v1/users' + '/' + userId + '/unblock' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + getMyScopedTokens (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/scoped-tokens' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + renewMyScopedTokens (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/scoped-tokens' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + create (options: OverrideCommandOptions & { + username: string + password?: string + videoQuota?: number + videoQuotaDaily?: number + role?: UserRoleType + adminFlags?: UserAdminFlagType + }) { + const { + username, + adminFlags, + password = 'password', + videoQuota, + videoQuotaDaily, + role = UserRole.USER + } = options + + const path = '/api/v1/users' + + return unwrapBody<{ user: UserCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: { + username, + password, + role, + adminFlags, + email: username + '@example.com', + videoQuota, + videoQuotaDaily + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })).then(res => res.user) + } + + async generate (username: string, role?: UserRoleType) { + const password = 'password' + const user = await this.create({ username, password, role }) + + const token = await this.server.login.getAccessToken({ username, password }) + + const me = await this.getMyInfo({ token }) + + return { + token, + userId: user.id, + userChannelId: me.videoChannels[0].id, + userChannelName: me.videoChannels[0].name, + password + } + } + + async generateUserAndToken (username: string, role?: UserRoleType) { + const password = 'password' + await this.create({ username, password, role }) + + return this.server.login.getAccessToken({ username, password }) + } + + // --------------------------------------------------------------------------- + + getMyInfo (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getMyQuotaUsed (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me/video-quota-used' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getMyRating (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + const path = '/api/v1/users/me/videos/' + videoId + '/rating' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteMe (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + updateMe (options: OverrideCommandOptions & UserUpdateMe) { + const path = '/api/v1/users/me' + + const toSend: UserUpdateMe = omit(options, [ 'expectedStatus', 'token' ]) + + return this.putBodyRequest({ + ...options, + + path, + fields: toSend, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + updateMyAvatar (options: OverrideCommandOptions & { + fixture: string + }) { + const { fixture } = options + const path = '/api/v1/users/me/avatar/pick' + + return this.updateImageRequest({ + ...options, + + path, + fixture, + fieldname: 'avatarfile', + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + get (options: OverrideCommandOptions & { + userId: number + withStats?: boolean // default false + }) { + const { userId, withStats } = options + const path = '/api/v1/users/' + userId + + return this.getRequestBody({ + ...options, + + path, + query: { withStats }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + blocked?: boolean + } = {}) { + const path = '/api/v1/users' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search', 'blocked' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + remove (options: OverrideCommandOptions & { + userId: number + }) { + const { userId } = options + const path = '/api/v1/users/' + userId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + update (options: OverrideCommandOptions & { + userId: number + email?: string + emailVerified?: boolean + videoQuota?: number + videoQuotaDaily?: number + password?: string + adminFlags?: UserAdminFlagType + pluginAuth?: string + role?: UserRoleType + }) { + const path = '/api/v1/users/' + options.userId + + const toSend: UserUpdate = {} + if (options.password !== undefined && options.password !== null) toSend.password = options.password + if (options.email !== undefined && options.email !== null) toSend.email = options.email + if (options.emailVerified !== undefined && options.emailVerified !== null) toSend.emailVerified = options.emailVerified + if (options.videoQuota !== undefined && options.videoQuota !== null) toSend.videoQuota = options.videoQuota + if (options.videoQuotaDaily !== undefined && options.videoQuotaDaily !== null) toSend.videoQuotaDaily = options.videoQuotaDaily + if (options.role !== undefined && options.role !== null) toSend.role = options.role + if (options.adminFlags !== undefined && options.adminFlags !== null) toSend.adminFlags = options.adminFlags + if (options.pluginAuth !== undefined) toSend.pluginAuth = options.pluginAuth + + return this.putBodyRequest({ + ...options, + + path, + fields: toSend, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/blacklist-command.ts b/packages/server-commands/src/videos/blacklist-command.ts new file mode 100644 index 000000000..d41001e26 --- /dev/null +++ b/packages/server-commands/src/videos/blacklist-command.ts @@ -0,0 +1,74 @@ +import { HttpStatusCode, ResultList, VideoBlacklist, VideoBlacklistType_Type } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class BlacklistCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + videoId: number | string + reason?: string + unfederate?: boolean + }) { + const { videoId, reason, unfederate } = options + const path = '/api/v1/videos/' + videoId + '/blacklist' + + return this.postBodyRequest({ + ...options, + + path, + fields: { reason, unfederate }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + update (options: OverrideCommandOptions & { + videoId: number | string + reason?: string + }) { + const { videoId, reason } = options + const path = '/api/v1/videos/' + videoId + '/blacklist' + + return this.putBodyRequest({ + ...options, + + path, + fields: { reason }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + const path = '/api/v1/videos/' + videoId + '/blacklist' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + sort?: string + type?: VideoBlacklistType_Type + } = {}) { + const { sort, type } = options + const path = '/api/v1/videos/blacklist/' + + const query = { sort, type } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/captions-command.ts b/packages/server-commands/src/videos/captions-command.ts new file mode 100644 index 000000000..a8336aa27 --- /dev/null +++ b/packages/server-commands/src/videos/captions-command.ts @@ -0,0 +1,67 @@ +import { HttpStatusCode, ResultList, VideoCaption } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class CaptionsCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + videoId: string | number + language: string + fixture: string + mimeType?: string + }) { + const { videoId, language, fixture, mimeType } = options + + const path = '/api/v1/videos/' + videoId + '/captions/' + language + + const captionfile = buildAbsoluteFixturePath(fixture) + const captionfileAttach = mimeType + ? [ captionfile, { contentType: mimeType } ] + : captionfile + + return this.putUploadRequest({ + ...options, + + path, + fields: {}, + attaches: { + captionfile: captionfileAttach + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + videoId: string | number + videoPassword?: string + }) { + const { videoId, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/captions' + + return this.getRequestBody>({ + ...options, + + path, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + delete (options: OverrideCommandOptions & { + videoId: string | number + language: string + }) { + const { videoId, language } = options + const path = '/api/v1/videos/' + videoId + '/captions/' + language + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/change-ownership-command.ts b/packages/server-commands/src/videos/change-ownership-command.ts new file mode 100644 index 000000000..1dc7c2c0f --- /dev/null +++ b/packages/server-commands/src/videos/change-ownership-command.ts @@ -0,0 +1,67 @@ +import { HttpStatusCode, ResultList, VideoChangeOwnership } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ChangeOwnershipCommand extends AbstractCommand { + + create (options: OverrideCommandOptions & { + videoId: number | string + username: string + }) { + const { videoId, username } = options + const path = '/api/v1/videos/' + videoId + '/give-ownership' + + return this.postBodyRequest({ + ...options, + + path, + fields: { username }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/ownership' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort: '-createdAt' }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + accept (options: OverrideCommandOptions & { + ownershipId: number + channelId: number + }) { + const { ownershipId, channelId } = options + const path = '/api/v1/videos/ownership/' + ownershipId + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + fields: { channelId }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + refuse (options: OverrideCommandOptions & { + ownershipId: number + }) { + const { ownershipId } = options + const path = '/api/v1/videos/ownership/' + ownershipId + '/refuse' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/channel-syncs-command.ts b/packages/server-commands/src/videos/channel-syncs-command.ts new file mode 100644 index 000000000..718000c8a --- /dev/null +++ b/packages/server-commands/src/videos/channel-syncs-command.ts @@ -0,0 +1,55 @@ +import { HttpStatusCode, ResultList, VideoChannelSync, VideoChannelSyncCreate } from '@peertube/peertube-models' +import { pick } from '@peertube/peertube-core-utils' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ChannelSyncsCommand extends AbstractCommand { + private static readonly API_PATH = '/api/v1/video-channel-syncs' + + listByAccount (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + }) { + const { accountName, sort = 'createdAt' } = options + + const path = `/api/v1/accounts/${accountName}/video-channel-syncs` + + return this.getRequestBody>({ + ...options, + + path, + query: { sort, ...pick(options, [ 'start', 'count' ]) }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async create (options: OverrideCommandOptions & { + attributes: VideoChannelSyncCreate + }) { + return unwrapBody<{ videoChannelSync: VideoChannelSync }>(this.postBodyRequest({ + ...options, + + path: ChannelSyncsCommand.API_PATH, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + delete (options: OverrideCommandOptions & { + channelSyncId: number + }) { + const path = `${ChannelSyncsCommand.API_PATH}/${options.channelSyncId}` + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/channels-command.ts b/packages/server-commands/src/videos/channels-command.ts new file mode 100644 index 000000000..772677d39 --- /dev/null +++ b/packages/server-commands/src/videos/channels-command.ts @@ -0,0 +1,202 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + ActorFollow, + HttpStatusCode, + ResultList, + VideoChannel, + VideoChannelCreate, + VideoChannelCreateResult, + VideoChannelUpdate, + VideosImportInChannelCreate +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ChannelsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + withStats?: boolean + } = {}) { + const path = '/api/v1/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'withStats' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByAccount (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + withStats?: boolean + search?: string + }) { + const { accountName, sort = 'createdAt' } = options + const path = '/api/v1/accounts/' + accountName + '/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort, ...pick(options, [ 'start', 'count', 'withStats', 'search' ]) }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async create (options: OverrideCommandOptions & { + attributes: Partial + }) { + const path = '/api/v1/video-channels/' + + // Default attributes + const defaultAttributes = { + displayName: 'my super video channel', + description: 'my super channel description', + support: 'my super channel support' + } + const attributes = { ...defaultAttributes, ...options.attributes } + + const body = await unwrapBody<{ videoChannel: VideoChannelCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.videoChannel + } + + update (options: OverrideCommandOptions & { + channelName: string + attributes: VideoChannelUpdate + }) { + const { channelName, attributes } = options + const path = '/api/v1/video-channels/' + channelName + + return this.putBodyRequest({ + ...options, + + path, + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + channelName: string + }) { + const path = '/api/v1/video-channels/' + options.channelName + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + get (options: OverrideCommandOptions & { + channelName: string + }) { + const path = '/api/v1/video-channels/' + options.channelName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateImage (options: OverrideCommandOptions & { + fixture: string + channelName: string | number + type: 'avatar' | 'banner' + }) { + const { channelName, fixture, type } = options + + const path = `/api/v1/video-channels/${channelName}/${type}/pick` + + return this.updateImageRequest({ + ...options, + + path, + fixture, + fieldname: type + 'file', + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteImage (options: OverrideCommandOptions & { + channelName: string | number + type: 'avatar' | 'banner' + }) { + const { channelName, type } = options + + const path = `/api/v1/video-channels/${channelName}/${type}` + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + listFollowers (options: OverrideCommandOptions & { + channelName: string + start?: number + count?: number + sort?: string + search?: string + }) { + const { channelName, start, count, sort, search } = options + const path = '/api/v1/video-channels/' + channelName + '/followers' + + const query = { start, count, sort, search } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + importVideos (options: OverrideCommandOptions & VideosImportInChannelCreate & { + channelName: string + }) { + const { channelName, externalChannelUrl, videoChannelSyncId } = options + + const path = `/api/v1/video-channels/${channelName}/import-videos` + + return this.postBodyRequest({ + ...options, + + path, + fields: { externalChannelUrl, videoChannelSyncId }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/channels.ts b/packages/server-commands/src/videos/channels.ts new file mode 100644 index 000000000..e3487d024 --- /dev/null +++ b/packages/server-commands/src/videos/channels.ts @@ -0,0 +1,29 @@ +import { PeerTubeServer } from '../server/server.js' + +function setDefaultVideoChannel (servers: PeerTubeServer[]) { + const tasks: Promise[] = [] + + for (const server of servers) { + const p = server.users.getMyInfo() + .then(user => { server.store.channel = user.videoChannels[0] }) + + tasks.push(p) + } + + return Promise.all(tasks) +} + +async function setDefaultChannelAvatar (serversArg: PeerTubeServer | PeerTubeServer[], channelName: string = 'root_channel') { + const servers = Array.isArray(serversArg) + ? serversArg + : [ serversArg ] + + for (const server of servers) { + await server.channels.updateImage({ channelName, fixture: 'avatar.png', type: 'avatar' }) + } +} + +export { + setDefaultVideoChannel, + setDefaultChannelAvatar +} diff --git a/packages/server-commands/src/videos/comments-command.ts b/packages/server-commands/src/videos/comments-command.ts new file mode 100644 index 000000000..4835ae1fb --- /dev/null +++ b/packages/server-commands/src/videos/comments-command.ts @@ -0,0 +1,159 @@ +import { pick } from '@peertube/peertube-core-utils' +import { HttpStatusCode, ResultList, VideoComment, VideoCommentThreads, VideoCommentThreadTree } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class CommentsCommand extends AbstractCommand { + + private lastVideoId: number | string + private lastThreadId: number + private lastReplyId: number + + listForAdmin (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + isLocal?: boolean + onLocalVideo?: boolean + search?: string + searchAccount?: string + searchVideo?: string + } = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/videos/comments' + + const query = { sort, ...pick(options, [ 'start', 'count', 'isLocal', 'onLocalVideo', 'search', 'searchAccount', 'searchVideo' ]) } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listThreads (options: OverrideCommandOptions & { + videoId: number | string + videoPassword?: string + start?: number + count?: number + sort?: string + }) { + const { start, count, sort, videoId, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/comment-threads' + + return this.getRequestBody({ + ...options, + + path, + query: { start, count, sort }, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getThread (options: OverrideCommandOptions & { + videoId: number | string + threadId: number + }) { + const { videoId, threadId } = options + const path = '/api/v1/videos/' + videoId + '/comment-threads/' + threadId + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async createThread (options: OverrideCommandOptions & { + videoId: number | string + text: string + videoPassword?: string + }) { + const { videoId, text, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/comment-threads' + + const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ + ...options, + + path, + fields: { text }, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + this.lastThreadId = body.comment?.id + this.lastVideoId = videoId + + return body.comment + } + + async addReply (options: OverrideCommandOptions & { + videoId: number | string + toCommentId: number + text: string + videoPassword?: string + }) { + const { videoId, toCommentId, text, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId + + const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ + ...options, + + path, + fields: { text }, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + this.lastReplyId = body.comment?.id + + return body.comment + } + + async addReplyToLastReply (options: OverrideCommandOptions & { + text: string + }) { + return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastReplyId }) + } + + async addReplyToLastThread (options: OverrideCommandOptions & { + text: string + }) { + return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastThreadId }) + } + + async findCommentId (options: OverrideCommandOptions & { + videoId: number | string + text: string + }) { + const { videoId, text } = options + const { data } = await this.listThreads({ videoId, count: 25, sort: '-createdAt' }) + + return data.find(c => c.text === text).id + } + + delete (options: OverrideCommandOptions & { + videoId: number | string + commentId: number + }) { + const { videoId, commentId } = options + const path = '/api/v1/videos/' + videoId + '/comments/' + commentId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/history-command.ts b/packages/server-commands/src/videos/history-command.ts new file mode 100644 index 000000000..fd032504a --- /dev/null +++ b/packages/server-commands/src/videos/history-command.ts @@ -0,0 +1,54 @@ +import { HttpStatusCode, ResultList, Video } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class HistoryCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + search?: string + } = {}) { + const { search } = options + const path = '/api/v1/users/me/history/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: { + search + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + removeElement (options: OverrideCommandOptions & { + videoId: number + }) { + const { videoId } = options + const path = '/api/v1/users/me/history/videos/' + videoId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeAll (options: OverrideCommandOptions & { + beforeDate?: string + } = {}) { + const { beforeDate } = options + const path = '/api/v1/users/me/history/videos/remove' + + return this.postBodyRequest({ + ...options, + + path, + fields: { beforeDate }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/imports-command.ts b/packages/server-commands/src/videos/imports-command.ts new file mode 100644 index 000000000..1a1931d64 --- /dev/null +++ b/packages/server-commands/src/videos/imports-command.ts @@ -0,0 +1,76 @@ + +import { HttpStatusCode, ResultList, VideoImport, VideoImportCreate } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ImportsCommand extends AbstractCommand { + + importVideo (options: OverrideCommandOptions & { + attributes: (VideoImportCreate | { torrentfile?: string, previewfile?: string, thumbnailfile?: string }) + }) { + const { attributes } = options + const path = '/api/v1/videos/imports' + + let attaches: any = {} + if (attributes.torrentfile) attaches = { torrentfile: attributes.torrentfile } + if (attributes.thumbnailfile) attaches = { thumbnailfile: attributes.thumbnailfile } + if (attributes.previewfile) attaches = { previewfile: attributes.previewfile } + + return unwrapBody(this.postUploadRequest({ + ...options, + + path, + attaches, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + delete (options: OverrideCommandOptions & { + importId: number + }) { + const path = '/api/v1/videos/imports/' + options.importId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + cancel (options: OverrideCommandOptions & { + importId: number + }) { + const path = '/api/v1/videos/imports/' + options.importId + '/cancel' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getMyVideoImports (options: OverrideCommandOptions & { + sort?: string + targetUrl?: string + videoChannelSyncId?: number + search?: string + } = {}) { + const { sort, targetUrl, videoChannelSyncId, search } = options + const path = '/api/v1/users/me/videos/imports' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort, targetUrl, videoChannelSyncId, search }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/index.ts b/packages/server-commands/src/videos/index.ts new file mode 100644 index 000000000..970026d51 --- /dev/null +++ b/packages/server-commands/src/videos/index.ts @@ -0,0 +1,22 @@ +export * from './blacklist-command.js' +export * from './captions-command.js' +export * from './change-ownership-command.js' +export * from './channels.js' +export * from './channels-command.js' +export * from './channel-syncs-command.js' +export * from './comments-command.js' +export * from './history-command.js' +export * from './imports-command.js' +export * from './live-command.js' +export * from './live.js' +export * from './playlists-command.js' +export * from './services-command.js' +export * from './storyboard-command.js' +export * from './streaming-playlists-command.js' +export * from './comments-command.js' +export * from './video-studio-command.js' +export * from './video-token-command.js' +export * from './views-command.js' +export * from './videos-command.js' +export * from './video-passwords-command.js' +export * from './video-stats-command.js' diff --git a/packages/server-commands/src/videos/live-command.ts b/packages/server-commands/src/videos/live-command.ts new file mode 100644 index 000000000..793b64f40 --- /dev/null +++ b/packages/server-commands/src/videos/live-command.ts @@ -0,0 +1,339 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { readdir } from 'fs/promises' +import { join } from 'path' +import { omit, wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + LiveVideo, + LiveVideoCreate, + LiveVideoSession, + LiveVideoUpdate, + ResultList, + VideoCreateResult, + VideoDetails, + VideoPrivacy, + VideoPrivacyType, + VideoState, + VideoStateType +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { ObjectStorageCommand, PeerTubeServer } from '../server/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' +import { sendRTMPStream, testFfmpegStreamError } from './live.js' + +export class LiveCommand extends AbstractCommand { + + get (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/live' + + return this.getRequestBody({ + ...options, + + path: path + '/' + options.videoId, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + listSessions (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = `/api/v1/videos/live/${options.videoId}/sessions` + + return this.getRequestBody>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async findLatestSession (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { data: sessions } = await this.listSessions(options) + + return sessions[sessions.length - 1] + } + + getReplaySession (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = `/api/v1/videos/${options.videoId}/live-session` + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + update (options: OverrideCommandOptions & { + videoId: number | string + fields: LiveVideoUpdate + }) { + const { videoId, fields } = options + const path = '/api/v1/videos/live' + + return this.putBodyRequest({ + ...options, + + path: path + '/' + videoId, + fields, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async create (options: OverrideCommandOptions & { + fields: LiveVideoCreate + }) { + const { fields } = options + const path = '/api/v1/videos/live' + + const attaches: any = {} + if (fields.thumbnailfile) attaches.thumbnailfile = fields.thumbnailfile + if (fields.previewfile) attaches.previewfile = fields.previewfile + + const body = await unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({ + ...options, + + path, + attaches, + fields: omit(fields, [ 'thumbnailfile', 'previewfile' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.video + } + + async quickCreate (options: OverrideCommandOptions & { + saveReplay: boolean + permanentLive: boolean + privacy?: VideoPrivacyType + videoPasswords?: string[] + }) { + const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC, videoPasswords } = options + + const replaySettings = privacy === VideoPrivacy.PASSWORD_PROTECTED + ? { privacy: VideoPrivacy.PRIVATE } + : { privacy } + + const { uuid } = await this.create({ + ...options, + + fields: { + name: 'live', + permanentLive, + saveReplay, + replaySettings, + channelId: this.server.store.channel.id, + privacy, + videoPasswords + } + }) + + const video = await this.server.videos.getWithToken({ id: uuid }) + const live = await this.get({ videoId: uuid }) + + return { video, live } + } + + // --------------------------------------------------------------------------- + + async sendRTMPStreamInVideo (options: OverrideCommandOptions & { + videoId: number | string + fixtureName?: string + copyCodecs?: boolean + }) { + const { videoId, fixtureName, copyCodecs } = options + const videoLive = await this.get({ videoId }) + + return sendRTMPStream({ rtmpBaseUrl: videoLive.rtmpUrl, streamKey: videoLive.streamKey, fixtureName, copyCodecs }) + } + + async runAndTestStreamError (options: OverrideCommandOptions & { + videoId: number | string + shouldHaveError: boolean + }) { + const command = await this.sendRTMPStreamInVideo(options) + + return testFfmpegStreamError(command, options.shouldHaveError) + } + + // --------------------------------------------------------------------------- + + waitUntilPublished (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + return this.waitUntilState({ videoId, state: VideoState.PUBLISHED }) + } + + waitUntilWaiting (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + return this.waitUntilState({ videoId, state: VideoState.WAITING_FOR_LIVE }) + } + + waitUntilEnded (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + return this.waitUntilState({ videoId, state: VideoState.LIVE_ENDED }) + } + + async waitUntilSegmentGeneration (options: OverrideCommandOptions & { + server: PeerTubeServer + videoUUID: string + playlistNumber: number + segment: number + objectStorage?: ObjectStorageCommand + objectStorageBaseUrl?: string + }) { + const { + server, + objectStorage, + playlistNumber, + segment, + videoUUID, + objectStorageBaseUrl + } = options + + const segmentName = `${playlistNumber}-00000${segment}.ts` + const baseUrl = objectStorage + ? join(objectStorageBaseUrl || objectStorage.getMockPlaylistBaseUrl(), 'hls') + : server.url + '/static/streaming-playlists/hls' + + let error = true + + while (error) { + try { + // Check fragment exists + await this.getRawRequest({ + ...options, + + url: `${baseUrl}/${videoUUID}/${segmentName}`, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + + const video = await server.videos.get({ id: videoUUID }) + const hlsPlaylist = video.streamingPlaylists[0] + + // Check SHA generation + const shaBody = await server.streamingPlaylists.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, withRetry: !!objectStorage }) + if (!shaBody[segmentName]) { + throw new Error('Segment SHA does not exist') + } + + // Check fragment is in m3u8 playlist + const subPlaylist = await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/${playlistNumber}.m3u8` }) + if (!subPlaylist.includes(segmentName)) throw new Error('Fragment does not exist in playlist') + + error = false + } catch { + error = true + await wait(100) + } + } + } + + async waitUntilReplacedByReplay (options: OverrideCommandOptions & { + videoId: number | string + }) { + let video: VideoDetails + + do { + video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId }) + + await wait(500) + } while (video.isLive === true || video.state.id !== VideoState.PUBLISHED) + } + + // --------------------------------------------------------------------------- + + getSegmentFile (options: OverrideCommandOptions & { + videoUUID: string + playlistNumber: number + segment: number + objectStorage?: ObjectStorageCommand + }) { + const { playlistNumber, segment, videoUUID, objectStorage } = options + + const segmentName = `${playlistNumber}-00000${segment}.ts` + const baseUrl = objectStorage + ? objectStorage.getMockPlaylistBaseUrl() + : `${this.server.url}/static/streaming-playlists/hls` + + const url = `${baseUrl}/${videoUUID}/${segmentName}` + + return this.getRawRequest({ + ...options, + + url, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPlaylistFile (options: OverrideCommandOptions & { + videoUUID: string + playlistName: string + objectStorage?: ObjectStorageCommand + }) { + const { playlistName, videoUUID, objectStorage } = options + + const baseUrl = objectStorage + ? objectStorage.getMockPlaylistBaseUrl() + : `${this.server.url}/static/streaming-playlists/hls` + + const url = `${baseUrl}/${videoUUID}/${playlistName}` + + return this.getRawRequest({ + ...options, + + url, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + async countPlaylists (options: OverrideCommandOptions & { + videoUUID: string + }) { + const basePath = this.server.servers.buildDirectory('streaming-playlists') + const hlsPath = join(basePath, 'hls', options.videoUUID) + + const files = await readdir(hlsPath) + + return files.filter(f => f.endsWith('.m3u8')).length + } + + private async waitUntilState (options: OverrideCommandOptions & { + videoId: number | string + state: VideoStateType + }) { + let video: VideoDetails + + do { + video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId }) + + await wait(500) + } while (video.state.id !== options.state) + } +} diff --git a/packages/server-commands/src/videos/live.ts b/packages/server-commands/src/videos/live.ts new file mode 100644 index 000000000..05bfa1113 --- /dev/null +++ b/packages/server-commands/src/videos/live.ts @@ -0,0 +1,129 @@ +import { wait } from '@peertube/peertube-core-utils' +import { VideoDetails, VideoInclude, VideoPrivacy } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' +import truncate from 'lodash-es/truncate.js' +import { PeerTubeServer } from '../server/server.js' + +function sendRTMPStream (options: { + rtmpBaseUrl: string + streamKey: string + fixtureName?: string // default video_short.mp4 + copyCodecs?: boolean // default false +}) { + const { rtmpBaseUrl, streamKey, fixtureName = 'video_short.mp4', copyCodecs = false } = options + + const fixture = buildAbsoluteFixturePath(fixtureName) + + const command = ffmpeg(fixture) + command.inputOption('-stream_loop -1') + command.inputOption('-re') + + if (copyCodecs) { + command.outputOption('-c copy') + } else { + command.outputOption('-c:v libx264') + command.outputOption('-g 120') + command.outputOption('-x264-params "no-scenecut=1"') + command.outputOption('-r 60') + } + + command.outputOption('-f flv') + + const rtmpUrl = rtmpBaseUrl + '/' + streamKey + command.output(rtmpUrl) + + command.on('error', err => { + if (err?.message?.includes('Exiting normally')) return + + if (process.env.DEBUG) console.error(err) + }) + + if (process.env.DEBUG) { + command.on('stderr', data => console.log(data)) + command.on('stdout', data => console.log(data)) + } + + command.run() + + return command +} + +function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) { + return new Promise((res, rej) => { + command.on('error', err => { + return rej(err) + }) + + setTimeout(() => { + res() + }, successAfterMS) + }) +} + +async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) { + let error: Error + + try { + await waitFfmpegUntilError(command, 45000) + } catch (err) { + error = err + } + + await stopFfmpeg(command) + + if (shouldHaveError && !error) throw new Error('Ffmpeg did not have an error') + if (!shouldHaveError && error) throw error +} + +async function stopFfmpeg (command: FfmpegCommand) { + command.kill('SIGINT') + + await wait(500) +} + +async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) { + for (const server of servers) { + await server.live.waitUntilPublished({ videoId }) + } +} + +async function waitUntilLiveWaitingOnAllServers (servers: PeerTubeServer[], videoId: string) { + for (const server of servers) { + await server.live.waitUntilWaiting({ videoId }) + } +} + +async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServer[], videoId: string) { + for (const server of servers) { + await server.live.waitUntilReplacedByReplay({ videoId }) + } +} + +async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: VideoDetails) { + const include = VideoInclude.BLACKLISTED + const privacyOneOf = [ VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.PUBLIC, VideoPrivacy.UNLISTED ] + + const { data } = await server.videos.list({ token: server.accessToken, sort: '-publishedAt', include, privacyOneOf }) + + const videoNameSuffix = ` - ${new Date(liveDetails.publishedAt).toLocaleString()}` + const truncatedVideoName = truncate(liveDetails.name, { + length: 120 - videoNameSuffix.length + }) + const toFind = truncatedVideoName + videoNameSuffix + + return data.find(v => v.name === toFind) +} + +export { + sendRTMPStream, + waitFfmpegUntilError, + testFfmpegStreamError, + stopFfmpeg, + + waitUntilLivePublishedOnAllServers, + waitUntilLiveReplacedByReplayOnAllServers, + waitUntilLiveWaitingOnAllServers, + + findExternalSavedVideo +} diff --git a/packages/server-commands/src/videos/playlists-command.ts b/packages/server-commands/src/videos/playlists-command.ts new file mode 100644 index 000000000..2e483f318 --- /dev/null +++ b/packages/server-commands/src/videos/playlists-command.ts @@ -0,0 +1,281 @@ +import { omit, pick } from '@peertube/peertube-core-utils' +import { + BooleanBothQuery, + HttpStatusCode, + ResultList, + VideoExistInPlaylist, + VideoPlaylist, + VideoPlaylistCreate, + VideoPlaylistCreateResult, + VideoPlaylistElement, + VideoPlaylistElementCreate, + VideoPlaylistElementCreateResult, + VideoPlaylistElementUpdate, + VideoPlaylistReorder, + VideoPlaylistType_Type, + VideoPlaylistUpdate +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class PlaylistsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + playlistType?: VideoPlaylistType_Type + }) { + const path = '/api/v1/video-playlists' + const query = pick(options, [ 'start', 'count', 'sort', 'playlistType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByChannel (options: OverrideCommandOptions & { + handle: string + start?: number + count?: number + sort?: string + playlistType?: VideoPlaylistType_Type + }) { + const path = '/api/v1/video-channels/' + options.handle + '/video-playlists' + const query = pick(options, [ 'start', 'count', 'sort', 'playlistType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByAccount (options: OverrideCommandOptions & { + handle: string + start?: number + count?: number + sort?: string + search?: string + playlistType?: VideoPlaylistType_Type + }) { + const path = '/api/v1/accounts/' + options.handle + '/video-playlists' + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + playlistId: number | string + }) { + const { playlistId } = options + const path = '/api/v1/video-playlists/' + playlistId + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listVideos (options: OverrideCommandOptions & { + playlistId: number | string + start?: number + count?: number + query?: { nsfw?: BooleanBothQuery } + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos' + const query = options.query ?? {} + + return this.getRequestBody>({ + ...options, + + path, + query: { + ...query, + start: options.start, + count: options.count + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + delete (options: OverrideCommandOptions & { + playlistId: number | string + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async create (options: OverrideCommandOptions & { + attributes: VideoPlaylistCreate + }) { + const path = '/api/v1/video-playlists' + + const fields = omit(options.attributes, [ 'thumbnailfile' ]) + + const attaches = options.attributes.thumbnailfile + ? { thumbnailfile: options.attributes.thumbnailfile } + : {} + + const body = await unwrapBody<{ videoPlaylist: VideoPlaylistCreateResult }>(this.postUploadRequest({ + ...options, + + path, + fields, + attaches, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.videoPlaylist + } + + update (options: OverrideCommandOptions & { + attributes: VideoPlaylistUpdate + playlistId: number | string + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + + const fields = omit(options.attributes, [ 'thumbnailfile' ]) + + const attaches = options.attributes.thumbnailfile + ? { thumbnailfile: options.attributes.thumbnailfile } + : {} + + return this.putUploadRequest({ + ...options, + + path, + fields, + attaches, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async addElement (options: OverrideCommandOptions & { + playlistId: number | string + attributes: VideoPlaylistElementCreate | { videoId: string } + }) { + const attributes = { + ...options.attributes, + + videoId: await this.server.videos.getId({ ...options, uuid: options.attributes.videoId }) + } + + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos' + + const body = await unwrapBody<{ videoPlaylistElement: VideoPlaylistElementCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.videoPlaylistElement + } + + updateElement (options: OverrideCommandOptions & { + playlistId: number | string + elementId: number | string + attributes: VideoPlaylistElementUpdate + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId + + return this.putBodyRequest({ + ...options, + + path, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeElement (options: OverrideCommandOptions & { + playlistId: number | string + elementId: number + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + reorderElements (options: OverrideCommandOptions & { + playlistId: number | string + attributes: VideoPlaylistReorder + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/reorder' + + return this.postBodyRequest({ + ...options, + + path, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getPrivacies (options: OverrideCommandOptions = {}) { + const path = '/api/v1/video-playlists/privacies' + + return this.getRequestBody<{ [ id: number ]: string }>({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + videosExist (options: OverrideCommandOptions & { + videoIds: number[] + }) { + const { videoIds } = options + const path = '/api/v1/users/me/video-playlists/videos-exist' + + return this.getRequestBody({ + ...options, + + path, + query: { videoIds }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/services-command.ts b/packages/server-commands/src/videos/services-command.ts new file mode 100644 index 000000000..ade10cd3a --- /dev/null +++ b/packages/server-commands/src/videos/services-command.ts @@ -0,0 +1,29 @@ +import { HttpStatusCode } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ServicesCommand extends AbstractCommand { + + getOEmbed (options: OverrideCommandOptions & { + oembedUrl: string + format?: string + maxHeight?: number + maxWidth?: number + }) { + const path = '/services/oembed' + const query = { + url: options.oembedUrl, + format: options.format, + maxheight: options.maxHeight, + maxwidth: options.maxWidth + } + + return this.getRequest({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/storyboard-command.ts b/packages/server-commands/src/videos/storyboard-command.ts new file mode 100644 index 000000000..a692ad612 --- /dev/null +++ b/packages/server-commands/src/videos/storyboard-command.ts @@ -0,0 +1,19 @@ +import { HttpStatusCode, Storyboard } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class StoryboardCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + '/storyboards' + + return this.getRequestBody<{ storyboards: Storyboard[] }>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/streaming-playlists-command.ts b/packages/server-commands/src/videos/streaming-playlists-command.ts new file mode 100644 index 000000000..2406dd023 --- /dev/null +++ b/packages/server-commands/src/videos/streaming-playlists-command.ts @@ -0,0 +1,119 @@ +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { unwrapBody, unwrapBodyOrDecodeToJSON, unwrapTextOrDecode } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class StreamingPlaylistsCommand extends AbstractCommand { + + async get (options: OverrideCommandOptions & { + url: string + + videoFileToken?: string + reinjectVideoFileToken?: boolean + + withRetry?: boolean // default false + currentRetry?: number + }): Promise { + const { videoFileToken, reinjectVideoFileToken, expectedStatus, withRetry = false, currentRetry = 1 } = options + + try { + const result = await unwrapTextOrDecode(this.getRawRequest({ + ...options, + + url: options.url, + query: { + videoFileToken, + reinjectVideoFileToken + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + // master.m3u8 could be empty + if (!result && (!expectedStatus || expectedStatus === HttpStatusCode.OK_200)) { + throw new Error('Empty result') + } + + return result + } catch (err) { + if (!withRetry || currentRetry > 10) throw err + + await wait(250) + + return this.get({ + ...options, + + withRetry, + currentRetry: currentRetry + 1 + }) + } + } + + async getFragmentedSegment (options: OverrideCommandOptions & { + url: string + range?: string + + withRetry?: boolean // default false + currentRetry?: number + }) { + const { withRetry = false, currentRetry = 1 } = options + + try { + const result = await unwrapBody(this.getRawRequest({ + ...options, + + url: options.url, + range: options.range, + implicitToken: false, + responseType: 'application/octet-stream', + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return result + } catch (err) { + if (!withRetry || currentRetry > 10) throw err + + await wait(250) + + return this.getFragmentedSegment({ + ...options, + + withRetry, + currentRetry: currentRetry + 1 + }) + } + } + + async getSegmentSha256 (options: OverrideCommandOptions & { + url: string + + withRetry?: boolean // default false + currentRetry?: number + }) { + const { withRetry = false, currentRetry = 1 } = options + + try { + const result = await unwrapBodyOrDecodeToJSON<{ [ id: string ]: string }>(this.getRawRequest({ + ...options, + + url: options.url, + contentType: 'application/json', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return result + } catch (err) { + if (!withRetry || currentRetry > 10) throw err + + await wait(250) + + return this.getSegmentSha256({ + ...options, + + withRetry, + currentRetry: currentRetry + 1 + }) + } + } +} diff --git a/packages/server-commands/src/videos/video-passwords-command.ts b/packages/server-commands/src/videos/video-passwords-command.ts new file mode 100644 index 000000000..7a56311ca --- /dev/null +++ b/packages/server-commands/src/videos/video-passwords-command.ts @@ -0,0 +1,56 @@ +import { HttpStatusCode, ResultList, VideoPassword } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class VideoPasswordsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + videoId: number | string + start?: number + count?: number + sort?: string + }) { + const { start, count, sort, videoId } = options + const path = '/api/v1/videos/' + videoId + '/passwords' + + return this.getRequestBody>({ + ...options, + + path, + query: { start, count, sort }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateAll (options: OverrideCommandOptions & { + videoId: number | string + passwords: string[] + }) { + const { videoId, passwords } = options + const path = `/api/v1/videos/${videoId}/passwords` + + return this.putBodyRequest({ + ...options, + path, + fields: { passwords }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove (options: OverrideCommandOptions & { + id: number + videoId: number | string + }) { + const { id, videoId } = options + const path = `/api/v1/videos/${videoId}/passwords/${id}` + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/video-stats-command.ts b/packages/server-commands/src/videos/video-stats-command.ts new file mode 100644 index 000000000..1b7a9b592 --- /dev/null +++ b/packages/server-commands/src/videos/video-stats-command.ts @@ -0,0 +1,62 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + VideoStatsOverall, + VideoStatsRetention, + VideoStatsTimeserie, + VideoStatsTimeserieMetric +} from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class VideoStatsCommand extends AbstractCommand { + + getOverallStats (options: OverrideCommandOptions & { + videoId: number | string + startDate?: string + endDate?: string + }) { + const path = '/api/v1/videos/' + options.videoId + '/stats/overall' + + return this.getRequestBody({ + ...options, + path, + + query: pick(options, [ 'startDate', 'endDate' ]), + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getTimeserieStats (options: OverrideCommandOptions & { + videoId: number | string + metric: VideoStatsTimeserieMetric + startDate?: Date + endDate?: Date + }) { + const path = '/api/v1/videos/' + options.videoId + '/stats/timeseries/' + options.metric + + return this.getRequestBody({ + ...options, + path, + + query: pick(options, [ 'startDate', 'endDate' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getRetentionStats (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/stats/retention' + + return this.getRequestBody({ + ...options, + path, + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/videos/video-studio-command.ts b/packages/server-commands/src/videos/video-studio-command.ts new file mode 100644 index 000000000..8c5ff169a --- /dev/null +++ b/packages/server-commands/src/videos/video-studio-command.ts @@ -0,0 +1,67 @@ +import { HttpStatusCode, VideoStudioTask } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class VideoStudioCommand extends AbstractCommand { + + static getComplexTask (): VideoStudioTask[] { + return [ + // Total duration: 2 + { + name: 'cut', + options: { + start: 1, + end: 3 + } + }, + + // Total duration: 7 + { + name: 'add-outro', + options: { + file: 'video_short.webm' + } + }, + + { + name: 'add-watermark', + options: { + file: 'custom-thumbnail.png' + } + }, + + // Total duration: 9 + { + name: 'add-intro', + options: { + file: 'video_very_short_240p.mp4' + } + } + ] + } + + createEditionTasks (options: OverrideCommandOptions & { + videoId: number | string + tasks: VideoStudioTask[] + }) { + const path = '/api/v1/videos/' + options.videoId + '/studio/edit' + const attaches: { [id: string]: any } = {} + + for (let i = 0; i < options.tasks.length; i++) { + const task = options.tasks[i] + + if (task.name === 'add-intro' || task.name === 'add-outro' || task.name === 'add-watermark') { + attaches[`tasks[${i}][options][file]`] = task.options.file + } + } + + return this.postUploadRequest({ + ...options, + + path, + attaches, + fields: { tasks: options.tasks }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/video-token-command.ts b/packages/server-commands/src/videos/video-token-command.ts new file mode 100644 index 000000000..5812e484a --- /dev/null +++ b/packages/server-commands/src/videos/video-token-command.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { HttpStatusCode, VideoToken } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class VideoTokenCommand extends AbstractCommand { + + create (options: OverrideCommandOptions & { + videoId: number | string + videoPassword?: string + }) { + const { videoId, videoPassword } = options + const path = '/api/v1/videos/' + videoId + '/token' + + return unwrapBody(this.postBodyRequest({ + ...options, + headers: this.buildVideoPasswordHeader(videoPassword), + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + async getVideoFileToken (options: OverrideCommandOptions & { + videoId: number | string + videoPassword?: string + }) { + const { files } = await this.create(options) + + return files.token + } +} diff --git a/packages/server-commands/src/videos/videos-command.ts b/packages/server-commands/src/videos/videos-command.ts new file mode 100644 index 000000000..72dc58a4b --- /dev/null +++ b/packages/server-commands/src/videos/videos-command.ts @@ -0,0 +1,831 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { expect } from 'chai' +import { createReadStream } from 'fs' +import { stat } from 'fs/promises' +import got, { Response as GotResponse } from 'got' +import validator from 'validator' +import { getAllPrivacies, omit, pick, wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + HttpStatusCodeType, + ResultList, + UserVideoRateType, + Video, + VideoCreate, + VideoCreateResult, + VideoDetails, + VideoFileMetadata, + VideoInclude, + VideoPrivacy, + VideoPrivacyType, + VideosCommonQuery, + VideoSource, + VideoTranscodingCreate +} from '@peertube/peertube-models' +import { buildAbsoluteFixturePath, buildUUID } from '@peertube/peertube-node-utils' +import { unwrapBody } from '../requests/index.js' +import { waitJobs } from '../server/jobs.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export type VideoEdit = Partial> & { + fixture?: string + thumbnailfile?: string + previewfile?: string +} + +export class VideosCommand extends AbstractCommand { + + getCategories (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/categories' + + return this.getRequestBody<{ [id: number]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getLicences (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/licences' + + return this.getRequestBody<{ [id: number]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getLanguages (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/languages' + + return this.getRequestBody<{ [id: string]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPrivacies (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/privacies' + + return this.getRequestBody<{ [id in VideoPrivacyType]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + getDescription (options: OverrideCommandOptions & { + descriptionPath: string + }) { + return this.getRequestBody<{ description: string }>({ + ...options, + path: options.descriptionPath, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getFileMetadata (options: OverrideCommandOptions & { + url: string + }) { + return unwrapBody(this.getRawRequest({ + ...options, + + url: options.url, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + // --------------------------------------------------------------------------- + + rate (options: OverrideCommandOptions & { + id: number | string + rating: UserVideoRateType + videoPassword?: string + }) { + const { id, rating, videoPassword } = options + const path = '/api/v1/videos/' + id + '/rate' + + return this.putBodyRequest({ + ...options, + + path, + fields: { rating }, + headers: this.buildVideoPasswordHeader(videoPassword), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + get (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getWithToken (options: OverrideCommandOptions & { + id: number | string + }) { + return this.get({ + ...options, + + token: this.buildCommonRequestToken({ ...options, implicitToken: true }) + }) + } + + getWithPassword (options: OverrideCommandOptions & { + id: number | string + password?: string + }) { + const path = '/api/v1/videos/' + options.id + + return this.getRequestBody({ + ...options, + headers:{ + 'x-peertube-video-password': options.password + }, + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getSource (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + '/source' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async getId (options: OverrideCommandOptions & { + uuid: number | string + }) { + const { uuid } = options + + if (validator.default.isUUID('' + uuid) === false) return uuid as number + + const { id } = await this.get({ ...options, id: uuid }) + + return id + } + + async listFiles (options: OverrideCommandOptions & { + id: number | string + }) { + const video = await this.get(options) + + const files = video.files || [] + const hlsFiles = video.streamingPlaylists[0]?.files || [] + + return files.concat(hlsFiles) + } + + // --------------------------------------------------------------------------- + + listMyVideos (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + isLive?: boolean + channelId?: number + } = {}) { + const path = '/api/v1/users/me/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listMySubscriptionVideos (options: OverrideCommandOptions & VideosCommonQuery = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/users/me/subscriptions/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort, ...this.buildListQuery(options) }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + list (options: OverrideCommandOptions & VideosCommonQuery = {}) { + const path = '/api/v1/videos' + + const query = this.buildListQuery(options) + + return this.getRequestBody>({ + ...options, + + path, + query: { sort: 'name', ...query }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) { + return this.list({ + ...options, + + token: this.buildCommonRequestToken({ ...options, implicitToken: true }) + }) + } + + listAllForAdmin (options: OverrideCommandOptions & VideosCommonQuery = {}) { + const include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER + const nsfw = 'both' + const privacyOneOf = getAllPrivacies() + + return this.list({ + ...options, + + include, + nsfw, + privacyOneOf, + + token: this.buildCommonRequestToken({ ...options, implicitToken: true }) + }) + } + + listByAccount (options: OverrideCommandOptions & VideosCommonQuery & { + handle: string + }) { + const { handle, search } = options + const path = '/api/v1/accounts/' + handle + '/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: { search, ...this.buildListQuery(options) }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByChannel (options: OverrideCommandOptions & VideosCommonQuery & { + handle: string + }) { + const { handle } = options + const path = '/api/v1/video-channels/' + handle + '/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: this.buildListQuery(options), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + async find (options: OverrideCommandOptions & { + name: string + }) { + const { data } = await this.list(options) + + return data.find(v => v.name === options.name) + } + + // --------------------------------------------------------------------------- + + update (options: OverrideCommandOptions & { + id: number | string + attributes?: VideoEdit + }) { + const { id, attributes = {} } = options + const path = '/api/v1/videos/' + id + + // Upload request + if (attributes.thumbnailfile || attributes.previewfile) { + const attaches: any = {} + if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile + if (attributes.previewfile) attaches.previewfile = attributes.previewfile + + return this.putUploadRequest({ + ...options, + + path, + fields: options.attributes, + attaches: { + thumbnailfile: attributes.thumbnailfile, + previewfile: attributes.previewfile + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + return this.putBodyRequest({ + ...options, + + path, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + + return unwrapBody(this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + })) + } + + async removeAll () { + const { data } = await this.list() + + for (const v of data) { + await this.remove({ id: v.id }) + } + } + + // --------------------------------------------------------------------------- + + async upload (options: OverrideCommandOptions & { + attributes?: VideoEdit + mode?: 'legacy' | 'resumable' // default legacy + waitTorrentGeneration?: boolean // default true + completedExpectedStatus?: HttpStatusCodeType + } = {}) { + const { mode = 'legacy', waitTorrentGeneration = true } = options + let defaultChannelId = 1 + + try { + const { videoChannels } = await this.server.users.getMyInfo({ token: options.token }) + defaultChannelId = videoChannels[0].id + } catch (e) { /* empty */ } + + // Override default attributes + const attributes = { + name: 'my super video', + category: 5, + licence: 4, + language: 'zh', + channelId: defaultChannelId, + nsfw: true, + waitTranscoding: false, + description: 'my super description', + support: 'my super support text', + tags: [ 'tag' ], + privacy: VideoPrivacy.PUBLIC, + commentsEnabled: true, + downloadEnabled: true, + fixture: 'video_short.webm', + + ...options.attributes + } + + const created = mode === 'legacy' + ? await this.buildLegacyUpload({ ...options, attributes }) + : await this.buildResumeUpload({ ...options, path: '/api/v1/videos/upload-resumable', attributes }) + + // Wait torrent generation + const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 }) + if (expectedStatus === HttpStatusCode.OK_200 && waitTorrentGeneration) { + let video: VideoDetails + + do { + video = await this.getWithToken({ ...options, id: created.uuid }) + + await wait(50) + } while (!video.files[0].torrentUrl) + } + + return created + } + + async buildLegacyUpload (options: OverrideCommandOptions & { + attributes: VideoEdit + }): Promise { + const path = '/api/v1/videos/upload' + + return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({ + ...options, + + path, + fields: this.buildUploadFields(options.attributes), + attaches: this.buildUploadAttaches(options.attributes), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })).then(body => body.video || body as any) + } + + async buildResumeUpload (options: OverrideCommandOptions & { + path: string + attributes: { fixture?: string } & { [id: string]: any } + completedExpectedStatus?: HttpStatusCodeType // When the upload is finished + }): Promise { + const { path, attributes, expectedStatus = HttpStatusCode.OK_200, completedExpectedStatus } = options + + let size = 0 + let videoFilePath: string + let mimetype = 'video/mp4' + + if (attributes.fixture) { + videoFilePath = buildAbsoluteFixturePath(attributes.fixture) + size = (await stat(videoFilePath)).size + + if (videoFilePath.endsWith('.mkv')) { + mimetype = 'video/x-matroska' + } else if (videoFilePath.endsWith('.webm')) { + mimetype = 'video/webm' + } + } + + // Do not check status automatically, we'll check it manually + const initializeSessionRes = await this.prepareResumableUpload({ + ...options, + + path, + expectedStatus: null, + attributes, + size, + mimetype + }) + const initStatus = initializeSessionRes.status + + if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) { + const locationHeader = initializeSessionRes.header['location'] + expect(locationHeader).to.not.be.undefined + + const pathUploadId = locationHeader.split('?')[1] + + const result = await this.sendResumableChunks({ + ...options, + + path, + pathUploadId, + videoFilePath, + size, + expectedStatus: completedExpectedStatus + }) + + if (result.statusCode === HttpStatusCode.OK_200) { + await this.endResumableUpload({ + ...options, + + expectedStatus: HttpStatusCode.NO_CONTENT_204, + path, + pathUploadId + }) + } + + return result.body?.video || result.body as any + } + + const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200 + ? HttpStatusCode.CREATED_201 + : expectedStatus + + expect(initStatus).to.equal(expectedInitStatus) + + return initializeSessionRes.body.video || initializeSessionRes.body + } + + async prepareResumableUpload (options: OverrideCommandOptions & { + path: string + attributes: { fixture?: string } & { [id: string]: any } + size: number + mimetype: string + + originalName?: string + lastModified?: number + }) { + const { path, attributes, originalName, lastModified, size, mimetype } = options + + const attaches = this.buildUploadAttaches(omit(options.attributes, [ 'fixture' ])) + + const uploadOptions = { + ...options, + + path, + headers: { + 'X-Upload-Content-Type': mimetype, + 'X-Upload-Content-Length': size.toString() + }, + fields: { + filename: attributes.fixture, + originalName, + lastModified, + + ...this.buildUploadFields(options.attributes) + }, + + // Fixture will be sent later + attaches: this.buildUploadAttaches(omit(options.attributes, [ 'fixture' ])), + implicitToken: true, + + defaultExpectedStatus: null + } + + if (Object.keys(attaches).length === 0) return this.postBodyRequest(uploadOptions) + + return this.postUploadRequest(uploadOptions) + } + + sendResumableChunks (options: OverrideCommandOptions & { + pathUploadId: string + path: string + videoFilePath: string + size: number + contentLength?: number + contentRangeBuilder?: (start: number, chunk: any) => string + digestBuilder?: (chunk: any) => string + }) { + const { + path, + pathUploadId, + videoFilePath, + size, + contentLength, + contentRangeBuilder, + digestBuilder, + expectedStatus = HttpStatusCode.OK_200 + } = options + + let start = 0 + + const token = this.buildCommonRequestToken({ ...options, implicitToken: true }) + + const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 }) + const server = this.server + return new Promise>((resolve, reject) => { + readable.on('data', async function onData (chunk) { + try { + readable.pause() + + const byterangeStart = start + chunk.length - 1 + + const headers = { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/octet-stream', + 'Content-Range': contentRangeBuilder + ? contentRangeBuilder(start, chunk) + : `bytes ${start}-${byterangeStart}/${size}`, + 'Content-Length': contentLength ? contentLength + '' : chunk.length + '' + } + + if (digestBuilder) { + Object.assign(headers, { digest: digestBuilder(chunk) }) + } + + const res = await got<{ video: VideoCreateResult }>({ + url: new URL(path + '?' + pathUploadId, server.url).toString(), + method: 'put', + headers, + body: chunk, + responseType: 'json', + throwHttpErrors: false + }) + + start += chunk.length + + // Last request, check final status + if (byterangeStart + 1 === size) { + if (res.statusCode === expectedStatus) { + return resolve(res) + } + + if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) { + readable.off('data', onData) + + // eslint-disable-next-line max-len + const message = `Incorrect transient behaviour sending intermediary chunks. Status code is ${res.statusCode} instead of ${expectedStatus}` + return reject(new Error(message)) + } + } + + readable.resume() + } catch (err) { + reject(err) + } + }) + }) + } + + endResumableUpload (options: OverrideCommandOptions & { + path: string + pathUploadId: string + }) { + return this.deleteRequest({ + ...options, + + path: options.path, + rawQuery: options.pathUploadId, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + quickUpload (options: OverrideCommandOptions & { + name: string + nsfw?: boolean + privacy?: VideoPrivacyType + fixture?: string + videoPasswords?: string[] + }) { + const attributes: VideoEdit = { name: options.name } + if (options.nsfw) attributes.nsfw = options.nsfw + if (options.privacy) attributes.privacy = options.privacy + if (options.fixture) attributes.fixture = options.fixture + if (options.videoPasswords) attributes.videoPasswords = options.videoPasswords + + return this.upload({ ...options, attributes }) + } + + async randomUpload (options: OverrideCommandOptions & { + wait?: boolean // default true + additionalParams?: VideoEdit & { prefixName?: string } + } = {}) { + const { wait = true, additionalParams } = options + const prefixName = additionalParams?.prefixName || '' + const name = prefixName + buildUUID() + + const attributes = { name, ...additionalParams } + + const result = await this.upload({ ...options, attributes }) + + if (wait) await waitJobs([ this.server ]) + + return { ...result, name } + } + + // --------------------------------------------------------------------------- + + replaceSourceFile (options: OverrideCommandOptions & { + videoId: number | string + fixture: string + completedExpectedStatus?: HttpStatusCodeType + }) { + return this.buildResumeUpload({ + ...options, + + path: '/api/v1/videos/' + options.videoId + '/source/replace-resumable', + attributes: { fixture: options.fixture } + }) + } + + // --------------------------------------------------------------------------- + + removeHLSPlaylist (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/hls' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeHLSFile (options: OverrideCommandOptions & { + videoId: number | string + fileId: number + }) { + const path = '/api/v1/videos/' + options.videoId + '/hls/' + options.fileId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeAllWebVideoFiles (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/web-videos' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeWebVideoFile (options: OverrideCommandOptions & { + videoId: number | string + fileId: number + }) { + const path = '/api/v1/videos/' + options.videoId + '/web-videos/' + options.fileId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + runTranscoding (options: OverrideCommandOptions & VideoTranscodingCreate & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/transcoding' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'transcodingType', 'forceTranscoding' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + private buildListQuery (options: VideosCommonQuery) { + return pick(options, [ + 'start', + 'count', + 'sort', + 'nsfw', + 'isLive', + 'categoryOneOf', + 'licenceOneOf', + 'languageOneOf', + 'privacyOneOf', + 'tagsOneOf', + 'tagsAllOf', + 'isLocal', + 'include', + 'skipCount' + ]) + } + + private buildUploadFields (attributes: VideoEdit) { + return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ]) + } + + private buildUploadAttaches (attributes: VideoEdit) { + const attaches: { [ name: string ]: string } = {} + + for (const key of [ 'thumbnailfile', 'previewfile' ]) { + if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key]) + } + + if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture) + + return attaches + } +} diff --git a/packages/server-commands/src/videos/views-command.ts b/packages/server-commands/src/videos/views-command.ts new file mode 100644 index 000000000..048bd3fda --- /dev/null +++ b/packages/server-commands/src/videos/views-command.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ +import { HttpStatusCode, VideoViewEvent } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ViewsCommand extends AbstractCommand { + + view (options: OverrideCommandOptions & { + id: number | string + currentTime: number + viewEvent?: VideoViewEvent + xForwardedFor?: string + }) { + const { id, xForwardedFor, viewEvent, currentTime } = options + const path = '/api/v1/videos/' + id + '/views' + + return this.postBodyRequest({ + ...options, + + path, + xForwardedFor, + fields: { + currentTime, + viewEvent + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async simulateView (options: OverrideCommandOptions & { + id: number | string + xForwardedFor?: string + }) { + await this.view({ ...options, currentTime: 0 }) + await this.view({ ...options, currentTime: 5 }) + } + + async simulateViewer (options: OverrideCommandOptions & { + id: number | string + currentTimes: number[] + xForwardedFor?: string + }) { + let viewEvent: VideoViewEvent = 'seek' + + for (const currentTime of options.currentTimes) { + await this.view({ ...options, currentTime, viewEvent }) + + viewEvent = undefined + } + } +} diff --git a/packages/server-commands/tsconfig.json b/packages/server-commands/tsconfig.json new file mode 100644 index 000000000..eb942f295 --- /dev/null +++ b/packages/server-commands/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "references": [ + { "path": "../models" }, + { "path": "../core-utils" }, + { "path": "../typescript-utils" } + ] +} diff --git a/packages/tests/fixtures/60fps_720p_small.mp4 b/packages/tests/fixtures/60fps_720p_small.mp4 new file mode 100644 index 000000000..74bf968a4 Binary files /dev/null and b/packages/tests/fixtures/60fps_720p_small.mp4 differ diff --git a/packages/tests/fixtures/ap-json/mastodon/bad-body-http-signature.json b/packages/tests/fixtures/ap-json/mastodon/bad-body-http-signature.json new file mode 100644 index 000000000..4e7bc3af5 --- /dev/null +++ b/packages/tests/fixtures/ap-json/mastodon/bad-body-http-signature.json @@ -0,0 +1,93 @@ +{ + "headers": { + "user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)", + "host": "localhost", + "date": "Mon, 22 Oct 2018 13:34:22 GMT", + "accept-encoding": "gzip", + "digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=", + "content-type": "application/activity+json", + "signature": "keyId=\"http://localhost:3000/users/ronan2#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"oLKbgxdFXdXsHJ3x/UsG9Svu7oa8Dyqiy6Jif4wqNuhAqRVMRaG18f+dd2OcfFX3XRGF8p8flZkU6vvoEQBauTwGRGcgXAJuKC1zYIWGk+PeiW8lNUnE4qGapWcTiFnIo7FKauNdsgqg/tvgs1pQIdHkDDjZMI64twP7sTN/4vG1PCq+kyqi/DM+ORLi/W7vFuLVHt2Iz7ikfw/R3/mMtS4FwLops+tVYBQ2iQ9DVRhTwLKVbeL/LLVB/tdGzNZ4F4nImBAQQ9I7WpPM6J/k+cBmoEbrUKs8ptx9gbX3OSsl5wlvPVMNzU9F9yb2MrB/Y/J4qssKz+LbiaktKGj7OQ==\"", + "content-length": "2815" + }, + "body": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T13:34:18Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T13:34:18Z", + "url": "http://localhost:3000/@ronan2/100939547203370948", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zergzerg

", + "contentMap": { + "en": "

@ronan zergzerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T13:34:19Z", + "signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q==" + } + } +} diff --git a/packages/tests/fixtures/ap-json/mastodon/bad-http-signature.json b/packages/tests/fixtures/ap-json/mastodon/bad-http-signature.json new file mode 100644 index 000000000..098597db0 --- /dev/null +++ b/packages/tests/fixtures/ap-json/mastodon/bad-http-signature.json @@ -0,0 +1,93 @@ +{ + "headers": { + "user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)", + "host": "localhost", + "date": "Mon, 22 Oct 2018 13:34:22 GMT", + "accept-encoding": "gzip", + "digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=", + "content-type": "application/activity+json", + "signature": "keyId=\"http://localhost:3000/users/ronan2#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"oLKbgxdFXdXsHJ3x/UsG9Svu7oa8Dyqiy6Jif4wqNuhAqRVMRaG18f+dd2OcfFX3XRGF8p8flZkU6vvoEQBauTwGRGcgXAJuKC1zYIWGk+PeiW8lNUnE4qGapWcTiFnIo7FKauNdsgqg/tvgs1pQIdHkDDjZMI64twP7sTN/4vG1PCq+kyqi/DM+ORLi/W7vFuLVHt2Iz7ikfw/R3/mMtS4FwLops+tVYBQ2iQ9DVRhTwLKVbeL/LLVB/tdGzNZ4F4nImBAQQ9I7WpPM6J/k+cBmoEbrUKs8ptx9gbX3OSsl4wlvPVMNzU9F9yb2MrB/Y/J4qssKz+LbiaktKGj7OQ==\"", + "content-length": "2815" + }, + "body": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T13:34:18Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T13:34:18Z", + "url": "http://localhost:3000/@ronan2/100939547203370948", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zergzerg

", + "contentMap": { + "en": "

@ronan zergzerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T13:34:19Z", + "signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q==" + } + } +} diff --git a/packages/tests/fixtures/ap-json/mastodon/bad-public-key.json b/packages/tests/fixtures/ap-json/mastodon/bad-public-key.json new file mode 100644 index 000000000..73d18b3ad --- /dev/null +++ b/packages/tests/fixtures/ap-json/mastodon/bad-public-key.json @@ -0,0 +1,3 @@ +{ + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YyuthHtWWgDe0Fdgdp2\ndC5dTJsRqW6pFw5omIYYYjoES/WRewhVxEA54BhmxD3L1zChfx131N1TS8jVowhW\nm999jpUffKCCvLgYKIXETJDHiDeMONVx8wp7v9fS1HiFXo/E5und39gUMs14CMFZ\n6PE5jRV3r4XIKQJHQl7/X5n5FOb2934K+1TKUeBkbft/AushlKatYQakt3qHxpwx\nFvE+JjGo7QTnzdjaOx/e5QvojdGi2Kx4+jl77j2WVcSo5lOBz04OAVJtChtn82vS\nulPdDh3hZcDn+WK67yAhGP6AnzvOybZZS4zowlKiQ3kqjVVXKdl8gAsL4Y7MZ40R\nJQIDAQAB\n-----END PUBLIC KEY-----\n" +} diff --git a/packages/tests/fixtures/ap-json/mastodon/create-bad-signature.json b/packages/tests/fixtures/ap-json/mastodon/create-bad-signature.json new file mode 100644 index 000000000..2cd037241 --- /dev/null +++ b/packages/tests/fixtures/ap-json/mastodon/create-bad-signature.json @@ -0,0 +1,81 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T12:43:07Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T12:43:07Z", + "url": "http://localhost:3000/@ronan2/100939345950887698", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939345950887698", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zerg

", + "contentMap": { + "en": "

@ronan zerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T12:43:08Z", + "signatureValue": "Vgr8nA0agPr9TcA4BlX+MWhmuE+rBcoIJLpnPbm3E5SnOCXbgjEfEaTLqfuzzkKNsR3PBbkvi3YWK4/DxJ0zmpzSB7yy4NRzluQMVQHqJiFKXAX3Sr3fIrK24xkWW9/F207c1NpFajSGbgnFKBdtFE0e5VqwSrSoOJkZukZW/2ATSnsyzblieuUmvTWpD0PqpUOsynPjw+RqZnqPn0cjw1z2Dm7ZRt3trnyMTXFYZw5U/YuqMY2kpadD6vq780md8kXlJIylxG6ZrlO2jz9fJdnfuVq43d4QFNsBm1K1r2WtNqX+i+wiqh+u3PjF4pzXtl/a3hJOH18IfZnK7I21mQ==" + } +} diff --git a/packages/tests/fixtures/ap-json/mastodon/create.json b/packages/tests/fixtures/ap-json/mastodon/create.json new file mode 100644 index 000000000..0be271bb8 --- /dev/null +++ b/packages/tests/fixtures/ap-json/mastodon/create.json @@ -0,0 +1,81 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T12:43:07Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T12:43:07Z", + "url": "http://localhost:3000/@ronan2/100939345950887698", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939345950887698", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zerg

", + "contentMap": { + "en": "

@ronan zerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T12:43:08Z", + "signatureValue": "VgR8nA0agPr9TcA4BlX+MWhmuE+rBcoIJLpnPbm3E5SnOCXbgjEfEaTLqfuzzkKNsR3PBbkvi3YWK4/DxJ0zmpzSB7yy4NRzluQMVQHqJiFKXAX3Sr3fIrK24xkWW9/F207c1NpFajSGbgnFKBdtFE0e5VqwSrSoOJkZukZW/2ATSnsyzblieuUmvTWpD0PqpUOsynPjw+RqZnqPn0cjw1z2Dm7ZRt3trnyMTXFYZw5U/YuqMY2kpadD6vq780md8kXlJIylxG6ZrlO2jz9fJdnfuVq43d4QFNsBm1K1r2WtNqX+i+wiqh+u3PjF4pzXtl/a3hJOH18IfZnK7I21mQ==" + } +} diff --git a/packages/tests/fixtures/ap-json/mastodon/http-signature.json b/packages/tests/fixtures/ap-json/mastodon/http-signature.json new file mode 100644 index 000000000..4e7bc3af5 --- /dev/null +++ b/packages/tests/fixtures/ap-json/mastodon/http-signature.json @@ -0,0 +1,93 @@ +{ + "headers": { + "user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)", + "host": "localhost", + "date": "Mon, 22 Oct 2018 13:34:22 GMT", + "accept-encoding": "gzip", + "digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=", + "content-type": "application/activity+json", + "signature": "keyId=\"http://localhost:3000/users/ronan2#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"oLKbgxdFXdXsHJ3x/UsG9Svu7oa8Dyqiy6Jif4wqNuhAqRVMRaG18f+dd2OcfFX3XRGF8p8flZkU6vvoEQBauTwGRGcgXAJuKC1zYIWGk+PeiW8lNUnE4qGapWcTiFnIo7FKauNdsgqg/tvgs1pQIdHkDDjZMI64twP7sTN/4vG1PCq+kyqi/DM+ORLi/W7vFuLVHt2Iz7ikfw/R3/mMtS4FwLops+tVYBQ2iQ9DVRhTwLKVbeL/LLVB/tdGzNZ4F4nImBAQQ9I7WpPM6J/k+cBmoEbrUKs8ptx9gbX3OSsl5wlvPVMNzU9F9yb2MrB/Y/J4qssKz+LbiaktKGj7OQ==\"", + "content-length": "2815" + }, + "body": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T13:34:18Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T13:34:18Z", + "url": "http://localhost:3000/@ronan2/100939547203370948", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zergzerg

", + "contentMap": { + "en": "

@ronan zergzerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T13:34:19Z", + "signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q==" + } + } +} diff --git a/packages/tests/fixtures/ap-json/mastodon/public-key.json b/packages/tests/fixtures/ap-json/mastodon/public-key.json new file mode 100644 index 000000000..b7b9b8308 --- /dev/null +++ b/packages/tests/fixtures/ap-json/mastodon/public-key.json @@ -0,0 +1,3 @@ +{ + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YyuthHtWWgDe0Fdgdp2\ndC5dTJsRqW6pFw5omIYYYjoES/WRewhVxEA54BhmxD3L1zChfx131N1TS8jVowhW\nm999jpUffKCCvLgYKIXETJDHiDeMONVx8wp7v9fS1HiFXo/E5und39gUMs14CMFZ\n6PE5jRV3r4XIKQJHQl7/X5n5FOb2934K+1TKUeBkbft/AushlKatYQakt3qHxpwx\nFvE+JjGo7QTnzdjaOx/e5QvojdGi2Kx4+jl87j2WVcSo5lOBz04OAVJtChtn82vS\nulPdDh3hZcDn+WK67yAhGP6AnzvOybZZS4zowlKiQ3kqjVVXKdl8gAsL4Y7MZ40R\nJQIDAQAB\n-----END PUBLIC KEY-----\n" +} diff --git a/packages/tests/fixtures/ap-json/peertube/announce-without-context.json b/packages/tests/fixtures/ap-json/peertube/announce-without-context.json new file mode 100644 index 000000000..cda1c514c --- /dev/null +++ b/packages/tests/fixtures/ap-json/peertube/announce-without-context.json @@ -0,0 +1,13 @@ +{ + "type": "Announce", + "id": "http://127.0.0.1:9002/videos/watch/997111d4-e8d8-4f45-99d3-857905785d05/announces/1", + "actor": "http://127.0.0.1:9002/accounts/peertube", + "object": "http://127.0.0.1:9002/videos/watch/997111d4-e8d8-4f45-99d3-857905785d05", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "http://127.0.0.1:9002/accounts/peertube/followers", + "http://127.0.0.1:9002/video-channels/root_channel/followers", + "http://127.0.0.1:9002/accounts/root/followers" + ], + "cc": [] +} diff --git a/packages/tests/fixtures/ap-json/peertube/invalid-keys.json b/packages/tests/fixtures/ap-json/peertube/invalid-keys.json new file mode 100644 index 000000000..0544e96b9 --- /dev/null +++ b/packages/tests/fixtures/ap-json/peertube/invalid-keys.json @@ -0,0 +1,6 @@ +{ + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjQGdH6D3naKmSbbr/Df\nEh1H42F3WlHYXuxKLkm5Bemjdde+GwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYO\nwAyc3Zoy7afPNa4bZXqhJ1Im41rMGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55s\nIkczDkseJuadTvG+A1e4uNY2lnRmVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/F\npP5S75TS5l1DfJQIq2lp8RwrH6FvGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM\n7mS7eP8zF8lKXYUu8cjIscKm+XqGmyRoPyw2Pp53tew29idRUocVQHGBnlNbpKdd\naQIDAQAB\n-----END PUBLIC KEY-----\n", + "privateKey": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAqjQGdH6D3naKmSbbr/DfEh1H42F3WlHYXuxKLkm5Bemjdde+\nGwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYOwAyc3Zoy7afPNa4bZXqhJ1Im41rM\nGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55sIkczDkseJuadTvG+A1e4uNY2lnRm\nVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/FpP5S75TS5l1DfJQIq2lp8RwrH6Fv\nGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM7mS7eP8zF8lKXYUu8cjIscKm+XqG\nmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKddaQIDAQABAoIBAQCnBZawCtbtH/ay\ng+dhqEW/SOyavbKZ92cU/1tsQPxISRYXNjdf2VfK7HmVqC2S7NqBanz+AVZPHmda\n7OfamkSvQbFN5VvEy8ATNV+9HbG3HG78/MT9hZcGigmyJkcZuy4wILgoXCxfpxlD\netla60PB/4yioiRcmEIWjjOgpByphDJ7RuuuptyEvgjUjpPtvHK47O/loaD2HFJk\nbIYbRirbjUjITRjQxGVIvanqiwPG9pB26YDLxDOoXEumcnzRcEFWNdvoleaLgquS\nn/zVsXWEq4+1i7t44DDstWUt/2Bw5ksIkSdayQ6oy3vzre3YFHwvbVZ7qtQQgpru\nx+NIolZhAoGBAN1RgNj8zy9Py3SJdsoXtnuCItfD7eo7LWXUa06cM/NS695Q+/to\naa5i3cJnRlv+b+b3VvnhkhIBLfFQW+hWwPnnxJEehcm09ddN9zbWrZ4Yv9yYu+8d\nTLGyWL8kPFF1dz+29DcrSv3tXEOwxByX/O4U/X/i3wl2WhkybxVFnCuvAoGBAMTf\n91BgLzvcYKOxH+vRPOJY7g2HKGFe35R91M4E+9Eq1rq4LUQHBb3fhRh4+scNu0yb\nNfN1Zdx2nbgCXdTKomF1Ahxp58/A2iU65vVzL6hYfWXEGSmoBqsGCIpIxQ9jgB9k\nCl7t/Ban8Z/ORHTjI9fpHlSZyCWJ3ajepiM2a1ZnAoGAPpDO6wi1DXvyWVSPF1yS\nwuGsNfD2rjPihpoBZ+yypwP3GBcu1QjUb28Vn+KQOmt4eQPNO8DwCVT6BvEfulPk\nJAHISPom+jnFEgPBcmhIFpyKiLNI1bUjvExd2FNHFgQuHP38ligQAC782Un8dtTk\ntO2MKH4bbVJe8CaYzpuqJZMCgYABZyMpBHZxs8FQiUuT75rCdiXEHOlxwC5RrY/d\no/VzaR28mOFhsbcdwkD9iqcm0fc6tYRt5rFCH+pBzGqEwKjljuLj9vE67sHfMAtD\nRn3Zcj/6gKo5PMRHZbSb36bf1DKuhpT4VjPMqYe0PtEIEDJKMJQRwELH2bKlqGiA\nqbucEwKBgQCkS85JnpHEV/tSylsEEn2W3CQCx58zl7iZNV7h/tWMR4AyrcI0HqP6\nllJ7V/Cfw66MgelPnosKgagwLVI6gsqDtjnzYo3XuMRVlYIySJ/jV3eiUNkV2Ky2\nfp/gA9sVgp38QSr+xB9E0LNStcbqDzoCCcDRws/SK7PbkQH9KV47tQ==\n-----END RSA PRIVATE KEY-----" +} + + diff --git a/packages/tests/fixtures/ap-json/peertube/keys.json b/packages/tests/fixtures/ap-json/peertube/keys.json new file mode 100644 index 000000000..1a7700865 --- /dev/null +++ b/packages/tests/fixtures/ap-json/peertube/keys.json @@ -0,0 +1,4 @@ +{ + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjQGdH6D3naKmSbbr/Df\nEh1H42F3WlHYXuxKLkm5Bemjdde+GwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYO\nwAyc3Zoy7afPNa4bZXqhJ1Im41rMGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55s\nIkczDkseJuadTvG+A1e4uNY2lnRmVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/F\npP5S75TS5l1DfJQIq2lp8RwrH6FvGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM\n7mS7eP8zF8lKXYUu8cjIscKm+XqGmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKdd\naQIDAQAB\n-----END PUBLIC KEY-----\n", + "privateKey": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAqjQGdH6D3naKmSbbr/DfEh1H42F3WlHYXuxKLkm5Bemjdde+\nGwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYOwAyc3Zoy7afPNa4bZXqhJ1Im41rM\nGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55sIkczDkseJuadTvG+A1e4uNY2lnRm\nVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/FpP5S75TS5l1DfJQIq2lp8RwrH6Fv\nGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM7mS7eP8zF8lKXYUu8cjIscKm+XqG\nmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKddaQIDAQABAoIBAQCnBZawCtbtH/ay\ng+dhqEW/SOyavbKZ92cU/1tsQPxISRYXNjdf2VfK7HmVqC2S7NqBanz+AVZPHmda\n7OfamkSvQbFN5VvEy8ATNV+9HbG3HG78/MT9hZcGigmyJkcZuy4wILgoXCxfpxlD\netla60PB/4yioiRcmEIWjjOgpByphDJ7RuuuptyEvgjUjpPtvHK47O/loaD2HFJk\nbIYbRirbjUjITRjQxGVIvanqiwPG9pB26YDLxDOoXEumcnzRcEFWNdvoleaLgquS\nn/zVsXWEq4+1i7t44DDstWUt/2Bw5ksIkSdayQ6oy3vzre3YFHwvbVZ7qtQQgpru\nx+NIolZhAoGBAN1RgNj8zy9Py3SJdsoXtnuCItfD7eo7LWXUa06cM/NS695Q+/to\naa5i3cJnRlv+b+b3VvnhkhIBLfFQW+hWwPnnxJEehcm09ddN9zbWrZ4Yv9yYu+8d\nTLGyWL8kPFF1dz+29DcrSv3tXEOwxByX/O4U/X/i3wl2WhkybxVFnCuvAoGBAMTf\n91BgLzvcYKOxH+vRPOJY7g2HKGFe35R91M4E+9Eq1rq4LUQHBb3fhRh4+scNu0yb\nNfN1Zdx2nbgCXdTKomF1Ahxp58/A2iU65vVzL6hYfWXEGSmoBqsGCIpIxQ9jgB9k\nCl7t/Ban8Z/ORHTjI9fpHlSZyCWJ3ajepiM2a1ZnAoGAPpDO6wi1DXvyWVSPF1yS\nwuGsNfD2rjPihpoBZ+yypwP3GBcu1QjUb28Vn+KQOmt4eQPNO8DwCVT6BvEfulPk\nJAHISPom+jnFEgPBcmhIFpyKiLNI1bUjvExd2FNHFgQuHP38ligQAC782Un8dtTk\ntO2MKH4bbVJe8CaYzpuqJZMCgYABZyMpBHZxs8FQiUuT75rCdiXEHOlxwC5RrY/d\no/VzaR28mOFhsbcdwkD9iqcm0fc6tYRt5rFCH+pBzGqEwKjljuLj9vE67sHfMAtD\nRn3Zcj/6gKo5PMRHZbSb36bf1DKuhpT4VjPMqYe0PtEIEDJKMJQRwELH2bKlqGiA\nqbucEwKBgQCkS85JnpHEV/tSylsEEn2W3CQCx58zl7iZNV7h/tWMR4AyrcI0HqP6\nllJ7V/Cfw66MgelPnosKgagwLVI6gsqDtjnzYo3XuMRVlYIySJ/jV3eiUNkV2Ky2\nfp/gA9sVgp38QSr+xB9E0LNStcbqDzoCCcDRws/SK7PbkQH9KV47tQ==\n-----END RSA PRIVATE KEY-----" +} diff --git a/packages/tests/fixtures/avatar-big.png b/packages/tests/fixtures/avatar-big.png new file mode 100644 index 000000000..e593e40da Binary files /dev/null and b/packages/tests/fixtures/avatar-big.png differ diff --git a/packages/tests/fixtures/avatar-resized-120x120.gif b/packages/tests/fixtures/avatar-resized-120x120.gif new file mode 100644 index 000000000..81a82189e Binary files /dev/null and b/packages/tests/fixtures/avatar-resized-120x120.gif differ diff --git a/packages/tests/fixtures/avatar-resized-120x120.png b/packages/tests/fixtures/avatar-resized-120x120.png new file mode 100644 index 000000000..9d84151f8 Binary files /dev/null and b/packages/tests/fixtures/avatar-resized-120x120.png differ diff --git a/packages/tests/fixtures/avatar-resized-48x48.gif b/packages/tests/fixtures/avatar-resized-48x48.gif new file mode 100644 index 000000000..5900ff12e Binary files /dev/null and b/packages/tests/fixtures/avatar-resized-48x48.gif differ diff --git a/packages/tests/fixtures/avatar-resized-48x48.png b/packages/tests/fixtures/avatar-resized-48x48.png new file mode 100644 index 000000000..9e5f3b490 Binary files /dev/null and b/packages/tests/fixtures/avatar-resized-48x48.png differ diff --git a/packages/tests/fixtures/avatar.gif b/packages/tests/fixtures/avatar.gif new file mode 100644 index 000000000..f29707760 Binary files /dev/null and b/packages/tests/fixtures/avatar.gif differ diff --git a/packages/tests/fixtures/avatar.png b/packages/tests/fixtures/avatar.png new file mode 100644 index 000000000..4b7fd2c0a Binary files /dev/null and b/packages/tests/fixtures/avatar.png differ diff --git a/packages/tests/fixtures/avatar2-resized-120x120.png b/packages/tests/fixtures/avatar2-resized-120x120.png new file mode 100644 index 000000000..44149facb Binary files /dev/null and b/packages/tests/fixtures/avatar2-resized-120x120.png differ diff --git a/packages/tests/fixtures/avatar2-resized-48x48.png b/packages/tests/fixtures/avatar2-resized-48x48.png new file mode 100644 index 000000000..bb3939b1a Binary files /dev/null and b/packages/tests/fixtures/avatar2-resized-48x48.png differ diff --git a/packages/tests/fixtures/avatar2.png b/packages/tests/fixtures/avatar2.png new file mode 100644 index 000000000..dae702190 Binary files /dev/null and b/packages/tests/fixtures/avatar2.png differ diff --git a/packages/tests/fixtures/banner-resized.jpg b/packages/tests/fixtures/banner-resized.jpg new file mode 100644 index 000000000..952732d61 Binary files /dev/null and b/packages/tests/fixtures/banner-resized.jpg differ diff --git a/packages/tests/fixtures/banner.jpg b/packages/tests/fixtures/banner.jpg new file mode 100644 index 000000000..e5f284f59 Binary files /dev/null and b/packages/tests/fixtures/banner.jpg differ diff --git a/packages/tests/fixtures/custom-preview-big.png b/packages/tests/fixtures/custom-preview-big.png new file mode 100644 index 000000000..03d171af3 Binary files /dev/null and b/packages/tests/fixtures/custom-preview-big.png differ diff --git a/packages/tests/fixtures/custom-preview.jpg b/packages/tests/fixtures/custom-preview.jpg new file mode 100644 index 000000000..5a039d830 Binary files /dev/null and b/packages/tests/fixtures/custom-preview.jpg differ diff --git a/packages/tests/fixtures/custom-thumbnail-big.jpg b/packages/tests/fixtures/custom-thumbnail-big.jpg new file mode 100644 index 000000000..08375e425 Binary files /dev/null and b/packages/tests/fixtures/custom-thumbnail-big.jpg differ diff --git a/packages/tests/fixtures/custom-thumbnail.jpg b/packages/tests/fixtures/custom-thumbnail.jpg new file mode 100644 index 000000000..ef818442d Binary files /dev/null and b/packages/tests/fixtures/custom-thumbnail.jpg differ diff --git a/packages/tests/fixtures/custom-thumbnail.png b/packages/tests/fixtures/custom-thumbnail.png new file mode 100644 index 000000000..9f34daec1 Binary files /dev/null and b/packages/tests/fixtures/custom-thumbnail.png differ diff --git a/packages/tests/fixtures/exif.jpg b/packages/tests/fixtures/exif.jpg new file mode 100644 index 000000000..2997b38e9 Binary files /dev/null and b/packages/tests/fixtures/exif.jpg differ diff --git a/packages/tests/fixtures/exif.png b/packages/tests/fixtures/exif.png new file mode 100644 index 000000000..a1a0113f8 Binary files /dev/null and b/packages/tests/fixtures/exif.png differ diff --git a/packages/tests/fixtures/live/0-000067.ts b/packages/tests/fixtures/live/0-000067.ts new file mode 100644 index 000000000..a59f41a63 Binary files /dev/null and b/packages/tests/fixtures/live/0-000067.ts differ diff --git a/packages/tests/fixtures/live/0-000068.ts b/packages/tests/fixtures/live/0-000068.ts new file mode 100644 index 000000000..83dcbbb4c Binary files /dev/null and b/packages/tests/fixtures/live/0-000068.ts differ diff --git a/packages/tests/fixtures/live/0-000069.ts b/packages/tests/fixtures/live/0-000069.ts new file mode 100644 index 000000000..cafd4e978 Binary files /dev/null and b/packages/tests/fixtures/live/0-000069.ts differ diff --git a/packages/tests/fixtures/live/0-000070.ts b/packages/tests/fixtures/live/0-000070.ts new file mode 100644 index 000000000..0936199ea Binary files /dev/null and b/packages/tests/fixtures/live/0-000070.ts differ diff --git a/packages/tests/fixtures/live/0.m3u8 b/packages/tests/fixtures/live/0.m3u8 new file mode 100644 index 000000000..c3be19d26 --- /dev/null +++ b/packages/tests/fixtures/live/0.m3u8 @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:68 +#EXT-X-INDEPENDENT-SEGMENTS +#EXTINF:2.000000, +#EXT-X-PROGRAM-DATE-TIME:2023-04-18T13:38:39.019+0200 +0-000068.ts +#EXTINF:2.000000, +#EXT-X-PROGRAM-DATE-TIME:2023-04-18T13:38:41.019+0200 +0-000069.ts +#EXTINF:2.000000, +#EXT-X-PROGRAM-DATE-TIME:2023-04-18T13:38:43.019+0200 +0-000070. diff --git a/packages/tests/fixtures/live/1-000067.ts b/packages/tests/fixtures/live/1-000067.ts new file mode 100644 index 000000000..17db8f81e Binary files /dev/null and b/packages/tests/fixtures/live/1-000067.ts differ diff --git a/packages/tests/fixtures/live/1-000068.ts b/packages/tests/fixtures/live/1-000068.ts new file mode 100644 index 000000000..f7bb97040 Binary files /dev/null and b/packages/tests/fixtures/live/1-000068.ts differ diff --git a/packages/tests/fixtures/live/1-000069.ts b/packages/tests/fixtures/live/1-000069.ts new file mode 100644 index 000000000..64c791337 Binary files /dev/null and b/packages/tests/fixtures/live/1-000069.ts differ diff --git a/packages/tests/fixtures/live/1-000070.ts b/packages/tests/fixtures/live/1-000070.ts new file mode 100644 index 000000000..a5f04f109 Binary files /dev/null and b/packages/tests/fixtures/live/1-000070.ts differ diff --git a/packages/tests/fixtures/live/1.m3u8 b/packages/tests/fixtures/live/1.m3u8 new file mode 100644 index 000000000..26d7fa6b0 --- /dev/null +++ b/packages/tests/fixtures/live/1.m3u8 @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:2 +#EXT-X-MEDIA-SEQUENCE:68 +#EXT-X-INDEPENDENT-SEGMENTS +#EXTINF:2.000000, +#EXT-X-PROGRAM-DATE-TIME:2023-04-18T13:38:39.019+0200 +1-000068.ts +#EXTINF:2.000000, +#EXT-X-PROGRAM-DATE-TIME:2023-04-18T13:38:41.019+0200 +1-000069.ts +#EXTINF:2.000000, +#EXT-X-PROGRAM-DATE-TIME:2023-04-18T13:38:43.019+0200 +1-000070.ts diff --git a/packages/tests/fixtures/live/master.m3u8 b/packages/tests/fixtures/live/master.m3u8 new file mode 100644 index 000000000..7e52f33cf --- /dev/null +++ b/packages/tests/fixtures/live/master.m3u8 @@ -0,0 +1,8 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-STREAM-INF:BANDWIDTH=1287342,RESOLUTION=640x360,CODECS="avc1.64001f,mp4a.40.2" +0.m3u8 + +#EXT-X-STREAM-INF:BANDWIDTH=3051742,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2" +1.m3u8 + diff --git a/packages/tests/fixtures/low-bitrate.mp4 b/packages/tests/fixtures/low-bitrate.mp4 new file mode 100644 index 000000000..69004eccc Binary files /dev/null and b/packages/tests/fixtures/low-bitrate.mp4 differ diff --git a/packages/tests/fixtures/peertube-plugin-test-broken/main.js b/packages/tests/fixtures/peertube-plugin-test-broken/main.js new file mode 100644 index 000000000..afdb6f7a0 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-broken/main.js @@ -0,0 +1,12 @@ +async function register (options) { + options.unknownFunction() +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} diff --git a/packages/tests/fixtures/peertube-plugin-test-broken/package.json b/packages/tests/fixtures/peertube-plugin-test-broken/package.json new file mode 100644 index 000000000..fd03df216 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-broken/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-broken", + "version": "0.0.1", + "description": "Plugin test broken", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-external-auth-one/main.js b/packages/tests/fixtures/peertube-plugin-test-external-auth-one/main.js new file mode 100644 index 000000000..58bc27661 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-external-auth-one/main.js @@ -0,0 +1,85 @@ +async function register ({ + registerExternalAuth, + peertubeHelpers, + settingsManager, + unregisterExternalAuth +}) { + { + const result = registerExternalAuth({ + authName: 'external-auth-1', + authDisplayName: () => 'External Auth 1', + onLogout: user => peertubeHelpers.logger.info('On logout %s', user.username), + onAuthRequest: (req, res) => { + const username = req.query.username + + result.userAuthenticated({ + req, + res, + username, + email: username + '@example.com' + }) + } + }) + } + + { + const result = registerExternalAuth({ + authName: 'external-auth-2', + authDisplayName: () => 'External Auth 2', + onAuthRequest: (req, res) => { + result.userAuthenticated({ + req, + res, + username: 'kefka', + email: 'kefka@example.com', + role: 0, + displayName: 'Kefka Palazzo', + adminFlags: 1, + videoQuota: 42000, + videoQuotaDaily: 42100, + + // Always use new value except for videoQuotaDaily field + userUpdater: ({ fieldName, currentValue, newValue }) => { + if (fieldName === 'videoQuotaDaily') return currentValue + + return newValue + } + }) + }, + hookTokenValidity: (options) => { + if (options.type === 'refresh') { + return { valid: false } + } + + if (options.type === 'access') { + const token = options.token + const now = new Date() + now.setTime(now.getTime() - 5000) + + const createdAt = new Date(token.createdAt) + + return { valid: createdAt.getTime() >= now.getTime() } + } + + return { valid: true } + } + }) + } + + settingsManager.onSettingsChange(settings => { + if (settings.disableKefka) { + unregisterExternalAuth('external-auth-2') + } + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/packages/tests/fixtures/peertube-plugin-test-external-auth-one/package.json b/packages/tests/fixtures/peertube-plugin-test-external-auth-one/package.json new file mode 100644 index 000000000..22814b047 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-external-auth-one/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-external-auth-one", + "version": "0.0.1", + "description": "External auth one", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-external-auth-three/main.js b/packages/tests/fixtures/peertube-plugin-test-external-auth-three/main.js new file mode 100644 index 000000000..30cedccc6 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-external-auth-three/main.js @@ -0,0 +1,53 @@ +async function register ({ + registerExternalAuth, + peertubeHelpers +}) { + { + const result = registerExternalAuth({ + authName: 'external-auth-7', + authDisplayName: () => 'External Auth 7', + onAuthRequest: (req, res) => { + result.userAuthenticated({ + req, + res, + username: 'cid', + email: 'cid@example.com', + displayName: 'Cid Marquez' + }) + }, + onLogout: (user, req) => { + return 'https://example.com/redirectUrl' + } + }) + } + + { + const result = registerExternalAuth({ + authName: 'external-auth-8', + authDisplayName: () => 'External Auth 8', + onAuthRequest: (req, res) => { + result.userAuthenticated({ + req, + res, + username: 'cid', + email: 'cid@example.com', + displayName: 'Cid Marquez' + }) + }, + onLogout: (user, req) => { + return 'https://example.com/redirectUrl?access_token=' + req.headers['authorization'].split(' ')[1] + } + }) + } +} + +async function unregister () { + +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/packages/tests/fixtures/peertube-plugin-test-external-auth-three/package.json b/packages/tests/fixtures/peertube-plugin-test-external-auth-three/package.json new file mode 100644 index 000000000..f323d189d --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-external-auth-three/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-external-auth-three", + "version": "0.0.1", + "description": "External auth three", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-external-auth-two/main.js b/packages/tests/fixtures/peertube-plugin-test-external-auth-two/main.js new file mode 100644 index 000000000..755dbb62b --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-external-auth-two/main.js @@ -0,0 +1,95 @@ +async function register ({ + registerExternalAuth, + peertubeHelpers +}) { + { + const result = registerExternalAuth({ + authName: 'external-auth-3', + authDisplayName: () => 'External Auth 3', + onAuthRequest: (req, res) => { + result.userAuthenticated({ + req, + res, + username: 'cid', + email: 'cid@example.com', + displayName: 'Cid Marquez' + }) + } + }) + } + + { + const result = registerExternalAuth({ + authName: 'external-auth-4', + authDisplayName: () => 'External Auth 4', + onAuthRequest: (req, res) => { + result.userAuthenticated({ + req, + res, + username: 'kefka2', + email: 'kefka@example.com', + displayName: 'Kefka duplication' + }) + } + }) + } + + { + const result = registerExternalAuth({ + authName: 'external-auth-5', + authDisplayName: () => 'External Auth 5', + onAuthRequest: (req, res) => { + result.userAuthenticated({ + req, + res, + username: 'kefka', + email: 'kefka@example.com', + displayName: 'Kefka duplication' + }) + } + }) + } + + { + const result = registerExternalAuth({ + authName: 'external-auth-6', + authDisplayName: () => 'External Auth 6', + onAuthRequest: (req, res) => { + result.userAuthenticated({ + req, + res, + username: 'existing_user', + email: 'existing_user@example.com', + displayName: 'Existing user' + }) + } + }) + } + + { + const result = registerExternalAuth({ + authName: 'external-auth-7', + authDisplayName: () => 'External Auth 7', + onAuthRequest: (req, res) => { + result.userAuthenticated({ + req, + res, + username: 'existing_user2', + email: 'custom_email_existing_user2@example.com', + displayName: 'Existing user 2' + }) + } + }) + } +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/packages/tests/fixtures/peertube-plugin-test-external-auth-two/package.json b/packages/tests/fixtures/peertube-plugin-test-external-auth-two/package.json new file mode 100644 index 000000000..a5ca4d07a --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-external-auth-two/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-external-auth-two", + "version": "0.0.1", + "description": "External auth two", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json b/packages/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json new file mode 100644 index 000000000..52d8313df --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json @@ -0,0 +1,3 @@ +{ + "Hello world": "Bonjour le monde" +} diff --git a/packages/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json b/packages/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json new file mode 100644 index 000000000..9e187d83b --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json @@ -0,0 +1,3 @@ +{ + "Hello world": "Ciao, mondo!" +} diff --git a/packages/tests/fixtures/peertube-plugin-test-filter-translations/main.js b/packages/tests/fixtures/peertube-plugin-test-filter-translations/main.js new file mode 100644 index 000000000..71c11b2ba --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-filter-translations/main.js @@ -0,0 +1,21 @@ +async function register ({ registerHook, registerSetting, settingsManager, storageManager, peertubeHelpers }) { + registerHook({ + target: 'filter:api.videos.list.params', + handler: obj => addToCount(obj) + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ############################################################################ + +function addToCount (obj) { + return Object.assign({}, obj, { count: obj.count + 1 }) +} diff --git a/packages/tests/fixtures/peertube-plugin-test-filter-translations/package.json b/packages/tests/fixtures/peertube-plugin-test-filter-translations/package.json new file mode 100644 index 000000000..2adce4743 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-filter-translations/package.json @@ -0,0 +1,23 @@ +{ + "name": "peertube-plugin-test-filter-translations", + "version": "0.0.1", + "description": "Plugin test filter and translations", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": { + "fr-FR": "./languages/fr.json", + "it-IT": "./languages/it.json" + } +} diff --git a/packages/tests/fixtures/peertube-plugin-test-five/main.js b/packages/tests/fixtures/peertube-plugin-test-five/main.js new file mode 100644 index 000000000..07dd18654 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-five/main.js @@ -0,0 +1,23 @@ +async function register ({ + getRouter +}) { + const router = getRouter() + router.get('/ping', (req, res) => res.json({ message: 'pong' })) + + router.get('/is-authenticated', (req, res) => res.json({ isAuthenticated: res.locals.authenticated })) + + router.post('/form/post/mirror', (req, res) => { + res.json(req.body) + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/packages/tests/fixtures/peertube-plugin-test-five/package.json b/packages/tests/fixtures/peertube-plugin-test-five/package.json new file mode 100644 index 000000000..1f5d65d9d --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-five/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-five", + "version": "0.0.1", + "description": "Plugin test 5", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-four/main.js b/packages/tests/fixtures/peertube-plugin-test-four/main.js new file mode 100644 index 000000000..b10177b45 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-four/main.js @@ -0,0 +1,201 @@ +async function register ({ + peertubeHelpers, + registerHook, + getRouter +}) { + const logger = peertubeHelpers.logger + + logger.info('Hello world from plugin four') + + { + const username = 'root' + const results = await peertubeHelpers.database.query( + 'SELECT "email" from "user" WHERE "username" = $username', + { + type: 'SELECT', + bind: { username } + } + ) + + logger.info('root email is ' + results[0]['email']) + } + + { + registerHook({ + target: 'action:api.video.viewed', + handler: async ({ video }) => { + const videoFromDB1 = await peertubeHelpers.videos.loadByUrl(video.url) + const videoFromDB2 = await peertubeHelpers.videos.loadByIdOrUUID(video.id) + const videoFromDB3 = await peertubeHelpers.videos.loadByIdOrUUID(video.uuid) + + if (videoFromDB1.uuid !== videoFromDB2.uuid || videoFromDB2.uuid !== videoFromDB3.uuid) return + + logger.info('video from DB uuid is %s.', videoFromDB1.uuid) + + await peertubeHelpers.videos.removeVideo(video.id) + + logger.info('Video deleted by plugin four.') + } + }) + } + + { + const serverActor = await peertubeHelpers.server.getServerActor() + logger.info('server actor name is %s', serverActor.preferredUsername) + } + + { + logger.info('server url is %s', peertubeHelpers.config.getWebserverUrl()) + } + + { + const actions = { + blockServer, + unblockServer, + blockAccount, + unblockAccount, + blacklist, + unblacklist + } + + const router = getRouter() + router.post('/commander', async (req, res) => { + try { + await actions[req.body.command](peertubeHelpers, req.body) + + res.sendStatus(204) + } catch (err) { + logger.error('Error in commander.', { err }) + res.sendStatus(500) + } + }) + + router.get('/server-config', async (req, res) => { + const serverConfig = await peertubeHelpers.config.getServerConfig() + + return res.json({ serverConfig }) + }) + + router.get('/server-listening-config', async (req, res) => { + const config = await peertubeHelpers.config.getServerListeningConfig() + + return res.json({ config }) + }) + + router.get('/static-route', async (req, res) => { + const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute() + + return res.json({ staticRoute }) + }) + + router.get('/router-route', async (req, res) => { + const routerRoute = peertubeHelpers.plugin.getBaseRouterRoute() + + return res.json({ routerRoute }) + }) + + router.get('/user/:id', async (req, res) => { + const user = await peertubeHelpers.user.loadById(req.params.id) + if (!user) return res.status(404).end() + + return res.json({ + username: user.username + }) + }) + + router.get('/user', async (req, res) => { + const user = await peertubeHelpers.user.getAuthUser(res) + if (!user) return res.sendStatus(404) + + const isAdmin = user.role === 0 + const isModerator = user.role === 1 + const isUser = user.role === 2 + + return res.json({ + id: user.id, + username: user.username, + displayName: user.Account.name, + isAdmin, + isModerator, + isUser + }) + }) + + router.get('/video-files/:id', async (req, res) => { + const details = await peertubeHelpers.videos.getFiles(req.params.id) + if (!details) return res.sendStatus(404) + + return res.json(details) + }) + + router.get('/ffprobe', async (req, res) => { + const result = await peertubeHelpers.videos.ffprobe(req.query.path) + if (!result) return res.sendStatus(404) + + return res.json(result) + }) + + router.post('/send-notification', async (req, res) => { + peertubeHelpers.socket.sendNotification(req.body.userId, { + type: 1, + userId: req.body.userId + }) + + return res.sendStatus(201) + }) + + router.post('/send-video-live-new-state/:uuid', async (req, res) => { + const video = await peertubeHelpers.videos.loadByIdOrUUID(req.params.uuid) + peertubeHelpers.socket.sendVideoLiveNewState(video) + + return res.sendStatus(201) + }) + } + +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### + +async function blockServer (peertubeHelpers, body) { + const serverActor = await peertubeHelpers.server.getServerActor() + + await peertubeHelpers.moderation.blockServer({ byAccountId: serverActor.Account.id, hostToBlock: body.hostToBlock }) +} + +async function unblockServer (peertubeHelpers, body) { + const serverActor = await peertubeHelpers.server.getServerActor() + + await peertubeHelpers.moderation.unblockServer({ byAccountId: serverActor.Account.id, hostToUnblock: body.hostToUnblock }) +} + +async function blockAccount (peertubeHelpers, body) { + const serverActor = await peertubeHelpers.server.getServerActor() + + await peertubeHelpers.moderation.blockAccount({ byAccountId: serverActor.Account.id, handleToBlock: body.handleToBlock }) +} + +async function unblockAccount (peertubeHelpers, body) { + const serverActor = await peertubeHelpers.server.getServerActor() + + await peertubeHelpers.moderation.unblockAccount({ byAccountId: serverActor.Account.id, handleToUnblock: body.handleToUnblock }) +} + +async function blacklist (peertubeHelpers, body) { + await peertubeHelpers.moderation.blacklistVideo({ + videoIdOrUUID: body.videoUUID, + createOptions: body + }) +} + +async function unblacklist (peertubeHelpers, body) { + await peertubeHelpers.moderation.unblacklistVideo({ videoIdOrUUID: body.videoUUID }) +} diff --git a/packages/tests/fixtures/peertube-plugin-test-four/package.json b/packages/tests/fixtures/peertube-plugin-test-four/package.json new file mode 100644 index 000000000..dda3c7f37 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-four/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-four", + "version": "0.0.1", + "description": "Plugin test 4", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-one/main.js b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-one/main.js new file mode 100644 index 000000000..f58faa847 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-one/main.js @@ -0,0 +1,69 @@ +async function register ({ + registerIdAndPassAuth, + peertubeHelpers, + settingsManager, + unregisterIdAndPassAuth +}) { + registerIdAndPassAuth({ + authName: 'spyro-auth', + + onLogout: () => { + peertubeHelpers.logger.info('On logout for auth 1 - 1') + }, + + getWeight: () => 15, + + login (body) { + if (body.id === 'spyro' && body.password === 'spyro password') { + return Promise.resolve({ + username: 'spyro', + email: 'spyro@example.com', + role: 2, + displayName: 'Spyro the Dragon' + }) + } + + return null + } + }) + + registerIdAndPassAuth({ + authName: 'crash-auth', + + onLogout: () => { + peertubeHelpers.logger.info('On logout for auth 1 - 2') + }, + + getWeight: () => 50, + + login (body) { + if (body.id === 'crash' && body.password === 'crash password') { + return Promise.resolve({ + username: 'crash', + email: 'crash@example.com', + role: 1, + displayName: 'Crash Bandicoot' + }) + } + + return null + } + }) + + settingsManager.onSettingsChange(settings => { + if (settings.disableSpyro) { + unregisterIdAndPassAuth('spyro-auth') + } + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-one/package.json b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-one/package.json new file mode 100644 index 000000000..f8ad18a90 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-one/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-id-pass-auth-one", + "version": "0.0.1", + "description": "Id and pass auth one", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-three/main.js b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-three/main.js new file mode 100644 index 000000000..1200acfbd --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-three/main.js @@ -0,0 +1,106 @@ +async function register ({ + registerIdAndPassAuth, + peertubeHelpers +}) { + registerIdAndPassAuth({ + authName: 'laguna-bad-auth', + + onLogout: () => { + peertubeHelpers.logger.info('On logout for auth 3 - 1') + }, + + getWeight: () => 5, + + login (body) { + if (body.id === 'laguna' && body.password === 'laguna password') { + return Promise.resolve({ + username: 'laguna', + email: 'laguna@example.com', + displayName: 'Laguna Loire' + }) + } + + return null + } + }) + + registerIdAndPassAuth({ + authName: 'ward-auth', + + getWeight: () => 5, + + login (body) { + if (body.id === 'ward') { + return Promise.resolve({ + username: '-ward-42', + email: 'ward@example.com' + }) + } + + return null + } + }) + + registerIdAndPassAuth({ + authName: 'kiros-auth', + + getWeight: () => 5, + + login (body) { + if (body.id === 'kiros') { + return Promise.resolve({ + username: 'kiros', + email: 'kiros@example.com', + displayName: 'a'.repeat(5000) + }) + } + + return null + } + }) + + registerIdAndPassAuth({ + authName: 'raine-auth', + + getWeight: () => 5, + + login (body) { + if (body.id === 'raine') { + return Promise.resolve({ + username: 'raine', + email: 'raine@example.com', + role: 42 + }) + } + + return null + } + }) + + registerIdAndPassAuth({ + authName: 'ellone-auth', + + getWeight: () => 5, + + login (body) { + if (body.id === 'ellone') { + return Promise.resolve({ + username: 'ellone' + }) + } + + return null + } + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-three/package.json b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-three/package.json new file mode 100644 index 000000000..f9f107b1a --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-three/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-id-pass-auth-three", + "version": "0.0.1", + "description": "Id and pass auth three", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js new file mode 100644 index 000000000..fad5abf60 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js @@ -0,0 +1,65 @@ +async function register ({ + registerIdAndPassAuth, + peertubeHelpers +}) { + registerIdAndPassAuth({ + authName: 'laguna-auth', + + onLogout: () => { + peertubeHelpers.logger.info('On logout for auth 2 - 1') + }, + + getWeight: () => 30, + + hookTokenValidity: (options) => { + if (options.type === 'refresh') { + return { valid: false } + } + + if (options.type === 'access') { + const token = options.token + const now = new Date() + now.setTime(now.getTime() - 5000) + + const createdAt = new Date(token.createdAt) + + return { valid: createdAt.getTime() >= now.getTime() } + } + + return { valid: true } + }, + + login (body) { + if (body.id === 'laguna' && body.password === 'laguna password') { + return Promise.resolve({ + username: 'laguna', + email: 'laguna@example.com', + displayName: 'Laguna Loire', + adminFlags: 1, + videoQuota: 42000, + videoQuotaDaily: 42100, + + // Always use new value except for videoQuotaDaily field + userUpdater: ({ fieldName, currentValue, newValue }) => { + if (fieldName === 'videoQuotaDaily') return currentValue + + return newValue + } + }) + } + + return null + } + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-two/package.json b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-two/package.json new file mode 100644 index 000000000..5df15fac1 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-id-pass-auth-two/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-id-pass-auth-two", + "version": "0.0.1", + "description": "Id and pass auth two", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-native/main.js b/packages/tests/fixtures/peertube-plugin-test-native/main.js new file mode 100644 index 000000000..0390faea9 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-native/main.js @@ -0,0 +1,21 @@ +const print = require('a-native-example') + +async function register ({ getRouter }) { + print('hello world') + + const router = getRouter() + + router.get('/', (req, res) => { + print('hello world') + res.sendStatus(204) + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} diff --git a/packages/tests/fixtures/peertube-plugin-test-native/package.json b/packages/tests/fixtures/peertube-plugin-test-native/package.json new file mode 100644 index 000000000..a6525720b --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-native/package.json @@ -0,0 +1,23 @@ +{ + "name": "peertube-plugin-test-native", + "version": "0.0.1", + "description": "Plugin test-native", + "engine": { + "peertube": ">=4.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {}, + "dependencies": { + "a-native-example": "^1.0.0" + } +} diff --git a/packages/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js b/packages/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js new file mode 100644 index 000000000..ada4a70fe --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js @@ -0,0 +1,82 @@ +async function register ({ registerHook, registerSetting, settingsManager, storageManager, peertubeHelpers }) { + registerHook({ + target: 'filter:feed.podcast.rss.create-custom-xmlns.result', + handler: (result, params) => { + return result.concat([ + { + name: "biz", + value: "https://example.com/biz-xmlns", + }, + ]) + } + }) + + registerHook({ + target: 'filter:feed.podcast.channel.create-custom-tags.result', + handler: (result, params) => { + const { videoChannel } = params + return result.concat([ + { + name: "fooTag", + attributes: { "bar": "baz" }, + value: "42", + }, + { + name: "biz:videoChannel", + attributes: { "name": videoChannel.name, "id": videoChannel.id }, + }, + { + name: "biz:buzzItem", + value: [ + { + name: "nestedTag", + value: "example nested tag", + }, + ], + }, + ]) + } + }) + + registerHook({ + target: 'filter:feed.podcast.video.create-custom-tags.result', + handler: (result, params) => { + const { video, liveItem } = params + return result.concat([ + { + name: "fizzTag", + attributes: { "bar": "baz" }, + value: "21", + }, + { + name: "biz:video", + attributes: { "name": video.name, "id": video.id, "isLive": liveItem }, + }, + { + name: "biz:buzz", + value: [ + { + name: "nestedTag", + value: "example nested tag", + }, + ], + } + ]) + } + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ############################################################################ + +function addToCount (obj) { + return Object.assign({}, obj, { count: obj.count + 1 }) +} diff --git a/packages/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json b/packages/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json new file mode 100644 index 000000000..0f5a05a79 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json @@ -0,0 +1,19 @@ +{ + "name": "peertube-plugin-test-podcast-custom-tags", + "version": "0.0.1", + "description": "Plugin test custom tags in Podcast RSS feeds", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [] +} diff --git a/packages/tests/fixtures/peertube-plugin-test-six/main.js b/packages/tests/fixtures/peertube-plugin-test-six/main.js new file mode 100644 index 000000000..243b041e7 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-six/main.js @@ -0,0 +1,46 @@ +const fs = require('fs') +const path = require('path') + +async function register ({ + storageManager, + peertubeHelpers, + getRouter +}) { + const { logger } = peertubeHelpers + + { + await storageManager.storeData('superkey', { value: 'toto' }) + await storageManager.storeData('anotherkey', { value: 'toto2' }) + await storageManager.storeData('storedArrayKey', ['toto', 'toto2']) + + const result = await storageManager.getData('superkey') + logger.info('superkey stored value is %s', result.value) + + const storedArrayValue = await storageManager.getData('storedArrayKey') + logger.info('storedArrayKey isArray is %s', Array.isArray(storedArrayValue) ? 'true' : 'false') + logger.info('storedArrayKey stored value is %s', storedArrayValue.join(', ')) + } + + { + getRouter().get('/create-file', async (req, res) => { + const basePath = peertubeHelpers.plugin.getDataDirectoryPath() + + fs.writeFile(path.join(basePath, 'Aladdin.txt'), 'Prince Ali', function (err) { + if (err) return res.sendStatus(500) + + res.sendStatus(200) + }) + }) + } +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/packages/tests/fixtures/peertube-plugin-test-six/package.json b/packages/tests/fixtures/peertube-plugin-test-six/package.json new file mode 100644 index 000000000..8c97826b0 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-six/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-six", + "version": "0.0.1", + "description": "Plugin test 6", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-transcoding-one/main.js b/packages/tests/fixtures/peertube-plugin-test-transcoding-one/main.js new file mode 100644 index 000000000..c4ae777f5 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-transcoding-one/main.js @@ -0,0 +1,92 @@ +async function register ({ transcodingManager }) { + + // Output options + { + { + const builder = () => { + return { + outputOptions: [ + '-r 10' + ] + } + } + + transcodingManager.addVODProfile('libx264', 'low-vod', builder) + } + + { + const builder = (options) => { + return { + outputOptions: [ + '-r:' + options.streamNum + ' 50' + ] + } + } + + transcodingManager.addLiveProfile('libx264', 'high-live', builder) + } + } + + // Input options + { + { + const builder = () => { + return { + inputOptions: [ + '-r 5' + ] + } + } + + transcodingManager.addVODProfile('libx264', 'input-options-vod', builder) + } + + { + const builder = () => { + return { + inputOptions: [ + '-r 50' + ] + } + } + + transcodingManager.addLiveProfile('libx264', 'input-options-live', builder) + } + } + + // Scale filters + { + { + const builder = () => { + return { + scaleFilter: { + name: 'Glomgold' + } + } + } + + transcodingManager.addVODProfile('libx264', 'bad-scale-vod', builder) + } + + { + const builder = () => { + return { + scaleFilter: { + name: 'Flintheart' + } + } + } + + transcodingManager.addLiveProfile('libx264', 'bad-scale-live', builder) + } + } +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} diff --git a/packages/tests/fixtures/peertube-plugin-test-transcoding-one/package.json b/packages/tests/fixtures/peertube-plugin-test-transcoding-one/package.json new file mode 100644 index 000000000..bedbfa051 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-transcoding-one/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-transcoding-one", + "version": "0.0.1", + "description": "Plugin test transcoding 1", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-transcoding-two/main.js b/packages/tests/fixtures/peertube-plugin-test-transcoding-two/main.js new file mode 100644 index 000000000..a914bce49 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-transcoding-two/main.js @@ -0,0 +1,38 @@ +async function register ({ transcodingManager }) { + + { + const builder = () => { + return { + outputOptions: [] + } + } + + transcodingManager.addVODProfile('libopus', 'test-vod-profile', builder) + transcodingManager.addVODProfile('libvpx-vp9', 'test-vod-profile', builder) + + transcodingManager.addVODEncoderPriority('audio', 'libopus', 1000) + transcodingManager.addVODEncoderPriority('video', 'libvpx-vp9', 1000) + } + + { + const builder = (options) => { + return { + outputOptions: [ + '-b:' + options.streamNum + ' 10K' + ] + } + } + + transcodingManager.addLiveProfile('libopus', 'test-live-profile', builder) + transcodingManager.addLiveEncoderPriority('audio', 'libopus', 1000) + } +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} diff --git a/packages/tests/fixtures/peertube-plugin-test-transcoding-two/package.json b/packages/tests/fixtures/peertube-plugin-test-transcoding-two/package.json new file mode 100644 index 000000000..34be0454b --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-transcoding-two/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-transcoding-two", + "version": "0.0.1", + "description": "Plugin test transcoding 2", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-unloading/lib.js b/packages/tests/fixtures/peertube-plugin-test-unloading/lib.js new file mode 100644 index 000000000..f57e7cb01 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-unloading/lib.js @@ -0,0 +1,2 @@ +const d = new Date() +exports.value = d.getTime() diff --git a/packages/tests/fixtures/peertube-plugin-test-unloading/main.js b/packages/tests/fixtures/peertube-plugin-test-unloading/main.js new file mode 100644 index 000000000..5c8457cef --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-unloading/main.js @@ -0,0 +1,14 @@ +const lib = require('./lib') + +async function register ({ getRouter }) { + const router = getRouter() + router.get('/get', (req, res) => res.json({ message: lib.value })) +} + +async function unregister () { +} + +module.exports = { + register, + unregister +} diff --git a/packages/tests/fixtures/peertube-plugin-test-unloading/package.json b/packages/tests/fixtures/peertube-plugin-test-unloading/package.json new file mode 100644 index 000000000..7076d4b6f --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-unloading/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-unloading", + "version": "0.0.1", + "description": "Plugin test (modules unloading)", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-video-constants/main.js b/packages/tests/fixtures/peertube-plugin-test-video-constants/main.js new file mode 100644 index 000000000..06527bd35 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-video-constants/main.js @@ -0,0 +1,46 @@ +async function register ({ + videoCategoryManager, + videoLicenceManager, + videoLanguageManager, + videoPrivacyManager, + playlistPrivacyManager, + getRouter +}) { + videoLanguageManager.addConstant('al_bhed', 'Al Bhed') + videoLanguageManager.addLanguage('al_bhed2', 'Al Bhed 2') + videoLanguageManager.addConstant('al_bhed3', 'Al Bhed 3') + videoLanguageManager.deleteConstant('en') + videoLanguageManager.deleteLanguage('fr') + videoLanguageManager.deleteConstant('al_bhed3') + + videoCategoryManager.addCategory(42, 'Best category') + videoCategoryManager.addConstant(43, 'High best category') + videoCategoryManager.deleteConstant(1) // Music + videoCategoryManager.deleteCategory(2) // Films + + videoLicenceManager.addLicence(42, 'Best licence') + videoLicenceManager.addConstant(43, 'High best licence') + videoLicenceManager.deleteConstant(1) // Attribution + videoLicenceManager.deleteConstant(7) // Public domain + + videoPrivacyManager.deleteConstant(2) + videoPrivacyManager.deletePrivacy(2) + playlistPrivacyManager.deleteConstant(3) + playlistPrivacyManager.deletePlaylistPrivacy(3) + + { + const router = getRouter() + router.get('/reset-categories', (req, res) => { + videoCategoryManager.resetConstants() + + res.sendStatus(204) + }) + } +} + +async function unregister () {} + +module.exports = { + register, + unregister +} diff --git a/packages/tests/fixtures/peertube-plugin-test-video-constants/package.json b/packages/tests/fixtures/peertube-plugin-test-video-constants/package.json new file mode 100644 index 000000000..0fcf39933 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-video-constants/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-video-constants", + "version": "0.0.1", + "description": "Plugin test video constants", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test-websocket/main.js b/packages/tests/fixtures/peertube-plugin-test-websocket/main.js new file mode 100644 index 000000000..3fde76cfe --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-websocket/main.js @@ -0,0 +1,36 @@ +const WebSocketServer = require('ws').WebSocketServer + +async function register ({ + registerWebSocketRoute +}) { + const wss = new WebSocketServer({ noServer: true }) + + wss.on('connection', function connection(ws) { + ws.on('message', function message(data) { + if (data.toString() === 'ping') { + ws.send('pong') + } + }) + }) + + registerWebSocketRoute({ + route: '/toto', + + handler: (request, socket, head) => { + wss.handleUpgrade(request, socket, head, ws => { + wss.emit('connection', ws, request) + }) + } + }) +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ########################################################################### diff --git a/packages/tests/fixtures/peertube-plugin-test-websocket/package.json b/packages/tests/fixtures/peertube-plugin-test-websocket/package.json new file mode 100644 index 000000000..89c8baa04 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test-websocket/package.json @@ -0,0 +1,20 @@ +{ + "name": "peertube-plugin-test-websocket", + "version": "0.0.1", + "description": "Plugin test websocket", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": {} +} diff --git a/packages/tests/fixtures/peertube-plugin-test/languages/fr.json b/packages/tests/fixtures/peertube-plugin-test/languages/fr.json new file mode 100644 index 000000000..9e52f7065 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test/languages/fr.json @@ -0,0 +1,3 @@ +{ + "Hi": "Coucou" +} diff --git a/packages/tests/fixtures/peertube-plugin-test/main.js b/packages/tests/fixtures/peertube-plugin-test/main.js new file mode 100644 index 000000000..e16bf0ca3 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test/main.js @@ -0,0 +1,477 @@ +async function register ({ registerHook, registerSetting, settingsManager, storageManager, peertubeHelpers }) { + { + const actionHooks = [ + 'action:application.listening', + 'action:notifier.notification.created', + + 'action:api.video.updated', + 'action:api.video.deleted', + 'action:api.video.uploaded', + 'action:api.video.viewed', + + 'action:api.video.file-updated', + + 'action:api.video-channel.created', + 'action:api.video-channel.updated', + 'action:api.video-channel.deleted', + + 'action:api.live-video.created', + 'action:live.video.state.updated', + + 'action:api.video-thread.created', + 'action:api.video-comment-reply.created', + 'action:api.video-comment.deleted', + + 'action:api.video-caption.created', + 'action:api.video-caption.deleted', + + 'action:api.user.blocked', + 'action:api.user.unblocked', + 'action:api.user.registered', + 'action:api.user.created', + 'action:api.user.deleted', + 'action:api.user.updated', + 'action:api.user.oauth2-got-token', + + 'action:api.video-playlist-element.created' + ] + + for (const h of actionHooks) { + registerHook({ + target: h, + handler: () => peertubeHelpers.logger.debug('Run hook %s.', h) + }) + } + + for (const h of [ 'action:activity-pub.remote-video.created', 'action:activity-pub.remote-video.updated' ]) { + registerHook({ + target: h, + handler: ({ video, videoAPObject }) => { + peertubeHelpers.logger.debug('Run hook %s - AP %s - video %s.', h, video.name, videoAPObject.name ) + } + }) + } + } + + registerHook({ + target: 'filter:api.videos.list.params', + handler: obj => addToCount(obj) + }) + + registerHook({ + target: 'filter:api.videos.list.result', + handler: obj => addToTotal(obj) + }) + + registerHook({ + target: 'filter:api.video-playlist.videos.list.params', + handler: obj => addToCount(obj) + }) + + registerHook({ + target: 'filter:api.video-playlist.videos.list.result', + handler: obj => addToTotal(obj) + }) + + registerHook({ + target: 'filter:api.accounts.videos.list.params', + handler: obj => addToCount(obj) + }) + + registerHook({ + target: 'filter:api.accounts.videos.list.result', + handler: obj => addToTotal(obj, 2) + }) + + registerHook({ + target: 'filter:api.video-channels.videos.list.params', + handler: obj => addToCount(obj, 3) + }) + + registerHook({ + target: 'filter:api.video-channels.videos.list.result', + handler: obj => addToTotal(obj, 3) + }) + + registerHook({ + target: 'filter:api.user.me.videos.list.params', + handler: obj => addToCount(obj, 4) + }) + + registerHook({ + target: 'filter:api.user.me.videos.list.result', + handler: obj => addToTotal(obj, 4) + }) + + registerHook({ + target: 'filter:api.user.me.subscription-videos.list.params', + handler: obj => addToCount(obj) + }) + + registerHook({ + target: 'filter:api.user.me.subscription-videos.list.result', + handler: obj => addToTotal(obj, 4) + }) + + registerHook({ + target: 'filter:api.video.get.result', + handler: video => { + video.name += ' <3' + + return video + } + }) + + // --------------------------------------------------------------------------- + + registerHook({ + target: 'filter:api.video-channels.list.params', + handler: obj => addToCount(obj, 1) + }) + + registerHook({ + target: 'filter:api.video-channels.list.result', + handler: obj => addToTotal(obj, 1) + }) + + registerHook({ + target: 'filter:api.video-channel.get.result', + handler: channel => { + channel.name += ' <3' + + return channel + } + }) + + // --------------------------------------------------------------------------- + + for (const hook of [ 'filter:api.video.upload.accept.result', 'filter:api.live-video.create.accept.result' ]) { + registerHook({ + target: hook, + handler: ({ accepted }, { videoBody, liveVideoBody }) => { + if (!accepted) return { accepted: false } + + const name = videoBody + ? videoBody.name + : liveVideoBody.name + + if (name.indexOf('bad word') !== -1) return { accepted: false, errorMessage: 'bad word' } + + return { accepted: true } + } + }) + } + + registerHook({ + target: 'filter:api.video.update-file.accept.result', + handler: ({ accepted }, { videoFile }) => { + if (!accepted) return { accepted: false } + if (videoFile.filename.includes('webm')) return { accepted: false, errorMessage: 'no webm' } + + return { accepted: true } + } + }) + + registerHook({ + target: 'filter:api.video.pre-import-url.accept.result', + handler: ({ accepted }, { videoImportBody }) => { + if (!accepted) return { accepted: false } + if (videoImportBody.targetUrl.includes('bad')) return { accepted: false, errorMessage: 'bad target url' } + + return { accepted: true } + } + }) + + registerHook({ + target: 'filter:api.video.pre-import-torrent.accept.result', + handler: ({ accepted }, { videoImportBody }) => { + if (!accepted) return { accepted: false } + if (videoImportBody.name.includes('bad torrent')) return { accepted: false, errorMessage: 'bad torrent' } + + return { accepted: true } + } + }) + + registerHook({ + target: 'filter:api.video.post-import-url.accept.result', + handler: ({ accepted }, { video }) => { + if (!accepted) return { accepted: false } + if (video.name.includes('bad word')) return { accepted: false, errorMessage: 'bad word' } + + return { accepted: true } + } + }) + + registerHook({ + target: 'filter:api.video.post-import-torrent.accept.result', + handler: ({ accepted }, { video }) => { + if (!accepted) return { accepted: false } + if (video.name.includes('bad word')) return { accepted: false, errorMessage: 'bad word' } + + return { accepted: true } + } + }) + + // --------------------------------------------------------------------------- + + registerHook({ + target: 'filter:api.video-thread.create.accept.result', + handler: ({ accepted }, { commentBody }) => checkCommentBadWord(accepted, commentBody) + }) + + registerHook({ + target: 'filter:api.video-comment-reply.create.accept.result', + handler: ({ accepted }, { commentBody }) => checkCommentBadWord(accepted, commentBody) + }) + + registerHook({ + target: 'filter:activity-pub.remote-video-comment.create.accept.result', + handler: ({ accepted }, { comment }) => checkCommentBadWord(accepted, comment) + }) + + // --------------------------------------------------------------------------- + + registerHook({ + target: 'filter:activity-pub.activity.context.build.result', + handler: context => context.concat([ { recordedAt: 'https://schema.org/recordedAt' } ]) + }) + + registerHook({ + target: 'filter:activity-pub.video.json-ld.build.result', + handler: (jsonld, { video }) => ({ ...jsonld, videoName: video.name }) + }) + + // --------------------------------------------------------------------------- + + registerHook({ + target: 'filter:api.video-threads.list.params', + handler: obj => addToCount(obj) + }) + + registerHook({ + target: 'filter:api.video-threads.list.result', + handler: obj => addToTotal(obj) + }) + + registerHook({ + target: 'filter:api.video-thread-comments.list.result', + handler: obj => { + obj.data.forEach(c => c.text += ' <3') + + return obj + } + }) + + registerHook({ + target: 'filter:video.auto-blacklist.result', + handler: (blacklisted, { video }) => { + if (blacklisted) return true + if (video.name.includes('please blacklist me')) return true + + return false + } + }) + + { + registerHook({ + target: 'filter:api.user.signup.allowed.result', + handler: (result, params) => { + if (params && params.body && params.body.email && params.body.email.includes('jma 1')) { + return { allowed: false, errorMessage: 'No jma 1' } + } + + return result + } + }) + + registerHook({ + target: 'filter:api.user.request-signup.allowed.result', + handler: (result, params) => { + if (params && params.body && params.body.email && params.body.email.includes('jma 2')) { + return { allowed: false, errorMessage: 'No jma 2' } + } + + return result + } + }) + } + + registerHook({ + target: 'filter:api.download.torrent.allowed.result', + handler: (result, params) => { + if (params && params.downloadName.includes('bad torrent')) { + return { allowed: false, errorMessage: 'Liu Bei' } + } + + return result + } + }) + + registerHook({ + target: 'filter:api.download.video.allowed.result', + handler: async (result, params) => { + const loggedInUser = await peertubeHelpers.user.getAuthUser(params.res) + if (loggedInUser) return { allowed: true } + + if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) { + return { allowed: false, errorMessage: 'Cao Cao' } + } + + if (params && params.streamingPlaylist && params.video.name.includes('bad playlist file')) { + return { allowed: false, errorMessage: 'Sun Jian' } + } + + return result + } + }) + + // --------------------------------------------------------------------------- + + registerHook({ + target: 'filter:html.embed.video.allowed.result', + handler: (result, params) => { + return { + allowed: false, + html: 'Lu Bu' + } + } + }) + + registerHook({ + target: 'filter:html.embed.video-playlist.allowed.result', + handler: (result, params) => { + return { + allowed: false, + html: 'Diao Chan' + } + } + }) + + // --------------------------------------------------------------------------- + + registerHook({ + target: 'filter:html.client.json-ld.result', + handler: (jsonld, context) => { + if (!context || !context.video) return jsonld + + return Object.assign(jsonld, { recordedAt: 'http://example.com/recordedAt' }) + } + }) + + // --------------------------------------------------------------------------- + + registerHook({ + target: 'filter:api.server.stats.get.result', + handler: (result) => { + return { ...result, customStats: 14 } + } + }) + + registerHook({ + target: 'filter:job-queue.process.params', + handler: (object, context) => { + if (context.type !== 'video-studio-edition') return object + + object.data.tasks = [ + { + name: 'cut', + options: { + start: 0, + end: 1 + } + } + ] + + return object + } + }) + + registerHook({ + target: 'filter:transcoding.auto.resolutions-to-transcode.result', + handler: (object, context) => { + if (context.video.name.includes('transcode-filter')) { + object = [ 100 ] + } + + return object + } + }) + + // Upload/import/live attributes + for (const target of [ + 'filter:api.video.upload.video-attribute.result', + 'filter:api.video.import-url.video-attribute.result', + 'filter:api.video.import-torrent.video-attribute.result', + 'filter:api.video.live.video-attribute.result' + ]) { + registerHook({ + target, + handler: (result) => { + return { ...result, description: result.description + ' - ' + target } + } + }) + } + + { + const filterHooks = [ + 'filter:api.search.videos.local.list.params', + 'filter:api.search.videos.local.list.result', + 'filter:api.search.videos.index.list.params', + 'filter:api.search.videos.index.list.result', + 'filter:api.search.video-channels.local.list.params', + 'filter:api.search.video-channels.local.list.result', + 'filter:api.search.video-channels.index.list.params', + 'filter:api.search.video-channels.index.list.result', + 'filter:api.search.video-playlists.local.list.params', + 'filter:api.search.video-playlists.local.list.result', + 'filter:api.search.video-playlists.index.list.params', + 'filter:api.search.video-playlists.index.list.result', + + 'filter:api.overviews.videos.list.params', + 'filter:api.overviews.videos.list.result', + + 'filter:job-queue.process.params', + 'filter:job-queue.process.result' + ] + + for (const h of filterHooks) { + registerHook({ + target: h, + handler: (obj) => { + peertubeHelpers.logger.debug('Run hook %s.', h) + + return obj + } + }) + } + } +} + +async function unregister () { + return +} + +module.exports = { + register, + unregister +} + +// ############################################################################ + +function addToCount (obj, amount = 1) { + return Object.assign({}, obj, { count: obj.count + amount }) +} + +function addToTotal (result, amount = 1) { + return { + data: result.data, + total: result.total + amount + } +} + +function checkCommentBadWord (accepted, commentBody) { + if (!accepted) return { accepted: false } + if (commentBody.text.indexOf('bad word') !== -1) return { accepted: false, errorMessage: 'bad word '} + + return { accepted: true } +} diff --git a/packages/tests/fixtures/peertube-plugin-test/package.json b/packages/tests/fixtures/peertube-plugin-test/package.json new file mode 100644 index 000000000..108f21fd6 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test/package.json @@ -0,0 +1,22 @@ +{ + "name": "peertube-plugin-test", + "version": "0.0.1", + "description": "Plugin test", + "engine": { + "peertube": ">=1.3.0" + }, + "keywords": [ + "peertube", + "plugin" + ], + "homepage": "https://github.com/Chocobozzz/PeerTube", + "author": "Chocobozzz", + "bugs": "https://github.com/Chocobozzz/PeerTube/issues", + "library": "./main.js", + "staticDirs": {}, + "css": [], + "clientScripts": [], + "translations": { + "fr-FR": "./languages/fr.json" + } +} diff --git a/packages/tests/fixtures/rtmps.cert b/packages/tests/fixtures/rtmps.cert new file mode 100644 index 000000000..3ef606c52 --- /dev/null +++ b/packages/tests/fixtures/rtmps.cert @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIUKNycLAZUs2jFsWUW+zZhBkpLB2wwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMTExMDUxMDA4MzhaFw0yMTEy +MDUxMDA4MzhaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDak20d81KG/9mVLU6Qw/uRniC935yf9Rlp8FVCDxUd +zLbfHjrnIOv8kqinUI0nuEQC4DnF7Rbafe88WDU33Q8ixU/R0czUGq1AEwIjyN30 +5NjokCb26xWIly7RCfc/Ot6tjguHwKvcxqJMNC0Lit9Go9MDVnGFLkgHia68P72T +ZDVV44YpzwYDicwQs5C4nZ4yzAeclia07qfUY0VAEZlxJ/9zjwYHCT0AKaEPH35E +dUvjuvJ1OSHSN1S4acR+TPR3FwKQh3H/M/GWIqoiIOpdjFUBLs80QOM2aNrLmlyP +JtyFJLxCP7Ery9fGY/yzHeSxpgOKwZopD6uHZKi5yazNAgMBAAGjUzBRMB0GA1Ud +DgQWBBSSjhRQdWsybNQMLMhkwV+xiP2uoDAfBgNVHSMEGDAWgBSSjhRQdWsybNQM +LMhkwV+xiP2uoDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQC8 +rJu3J5sqVKNQaXOmLPd49RM7KG3Y1KPqbQi1lh+sW6aefZ9daeh3JDYGBZGPG/Fi +IMMP+LhGG0WqDm4ClK00wyNhBuNPEyzvuN/WMRX5djPxO1IZi+KogFwXsn853Ov9 +oV3nxArNNjDu2n92FiB7RTlXRXPIoRo2zEBcLvveGySn9XUazRzlqx6FAxYe2xsw +U3cZ6/wwU1YsEZa5bwIQk+gkFj3zDsTyEkn2ntcE2NlR+AhCHKa/yAxgPFycAVPX +2o+wNnc6H4syP98mMGj9hEE3RSJyCPgGBlgi7Swl64G3YygFPJzfLX9YTuxwr/eI +oitEjF9ljtmdEnf0RdOj +-----END CERTIFICATE----- diff --git a/packages/tests/fixtures/rtmps.key b/packages/tests/fixtures/rtmps.key new file mode 100644 index 000000000..14a85e70a --- /dev/null +++ b/packages/tests/fixtures/rtmps.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDak20d81KG/9mV +LU6Qw/uRniC935yf9Rlp8FVCDxUdzLbfHjrnIOv8kqinUI0nuEQC4DnF7Rbafe88 +WDU33Q8ixU/R0czUGq1AEwIjyN305NjokCb26xWIly7RCfc/Ot6tjguHwKvcxqJM +NC0Lit9Go9MDVnGFLkgHia68P72TZDVV44YpzwYDicwQs5C4nZ4yzAeclia07qfU +Y0VAEZlxJ/9zjwYHCT0AKaEPH35EdUvjuvJ1OSHSN1S4acR+TPR3FwKQh3H/M/GW +IqoiIOpdjFUBLs80QOM2aNrLmlyPJtyFJLxCP7Ery9fGY/yzHeSxpgOKwZopD6uH +ZKi5yazNAgMBAAECggEAND7C+UK8+jnTl13CBsZhrnfemaQGexGJ5pGkv2p9gKb7 +Gy/Nooty/OdNWtjdNJ5N22YfSRkXulgZxBHNfrHfOU9yedOtIxHRUZx5iXYs36mH +02cJeUHN3t1MOnkoWTvIGDH4vZUnP1lXV+Gs1rJ2Fht4h7a04cGjQ/H8C1EtDjqX +kzH2T/gwo5hdGrxifRTs5wCVoP/iUwNtBI4WrY2rfC6sV+NOICgp0xX0NvGWZ8UT +K1Ntpl8IxnxmeBd26d+Gbjc9d9fIRDtyXby4YOIlDZxnIiZEI0I452JqGl/jrXaP +F3Troet4OBj5uH5s374d6ubKq66XogiLMIjEj2tYfQKBgQDtuaOu+y549bFJKVc9 +TCiWSOl/0j2kKKG8UG23zMC//AT13WqZDT5ObfOAuMhy70au/PD84D9RU/+gRVWb +ptfybD9ugRNC8PkmdT82uYtZpS4+Xw4qyWVRgqQFmjSYz63cLcULVi8kiG8XmG5u +QGgT/tNv5mxhOMUGSxhClOpLBwKBgQDrYO9UrLs+gDVKbHF4Dh+YJpaLnwwF+TFA +j3ZbkE0XEeeXp/YDgyClmWwEkteJeNljtreCZ9gMkx3JdR9i8uecUQ2tFDBg3cN0 +BZAex2jFwSb0QbfzHNnE07I+aEIfHHjYXjzABl+1Yt95giKjce0Ke+8Zzahue0+9 +lYcAHemQiwKBgQCs9JAbIdJo3NBUW0iGZ19sH7YKciq4wXsSaC27OLPPugrd2m7Q +1arMIwCzWT01KdLyQ0MNqBVJFWT49RjYuuWIEauAuVYLMQkEKu+H4Cx7V0syw7Op ++4bEa9jr3op/1zE17PLcUaLQ4JZ6w0Ms4Z0XVyH72thlT4lBD+ehoXhohwKBgEtJ +LAPnY9Sv6Vuup/SAf/aIkSqDarMWa3x85pyO4Tl5zpuha3zgGjcdhYFI/ovIDbBp +JvUdBeuvup1PSwS5MP+8pSUxCfBRvkyD4v8VRRvLlgwWYSHvnm/oTmDLtCqDTtvV ++JRq9X3s7BHPYAjrTahGz8lvEGqWIoE/LHkLGEPVAoGAaF3VHuqDfmD9PJUAlsU1 +qxN7yfOd2ve0+66Ghus24DVqUFqwp5f2AxZXYUtSaNUp8fVbqIi+Yq3YDTU2KfId +5QNA/AiKi4VUNLElsG5DZlbszsE5KNp9fWQoggdQ5LND7AGEKeFERHOVQ7C5sc/C +omIqK5/PsZmaf4OZLyecxJY= +-----END PRIVATE KEY----- diff --git a/packages/tests/fixtures/sample.ogg b/packages/tests/fixtures/sample.ogg new file mode 100644 index 000000000..0d7f43eb7 Binary files /dev/null and b/packages/tests/fixtures/sample.ogg differ diff --git a/packages/tests/fixtures/subtitle-bad.txt b/packages/tests/fixtures/subtitle-bad.txt new file mode 100644 index 000000000..a2a30ae47 --- /dev/null +++ b/packages/tests/fixtures/subtitle-bad.txt @@ -0,0 +1,11 @@ +1 +00:00:01,600 --> 00:00:04,200 +English (US) + +2 +00:00:05,900 --> 00:00:07,999 +This is a subtitle in American English + +3 +00:00:10,000 --> 00:00:14,000 +Adding subtitles is very easy to do \ No newline at end of file diff --git a/packages/tests/fixtures/subtitle-good.srt b/packages/tests/fixtures/subtitle-good.srt new file mode 100644 index 000000000..a2a30ae47 --- /dev/null +++ b/packages/tests/fixtures/subtitle-good.srt @@ -0,0 +1,11 @@ +1 +00:00:01,600 --> 00:00:04,200 +English (US) + +2 +00:00:05,900 --> 00:00:07,999 +This is a subtitle in American English + +3 +00:00:10,000 --> 00:00:14,000 +Adding subtitles is very easy to do \ No newline at end of file diff --git a/packages/tests/fixtures/subtitle-good1.vtt b/packages/tests/fixtures/subtitle-good1.vtt new file mode 100644 index 000000000..04cd23946 --- /dev/null +++ b/packages/tests/fixtures/subtitle-good1.vtt @@ -0,0 +1,8 @@ +WEBVTT + +00:01.000 --> 00:04.000 +Subtitle good 1. + +00:05.000 --> 00:09.000 +- It will perforate your stomach. +- You could die. \ No newline at end of file diff --git a/packages/tests/fixtures/subtitle-good2.vtt b/packages/tests/fixtures/subtitle-good2.vtt new file mode 100644 index 000000000..4d3256def --- /dev/null +++ b/packages/tests/fixtures/subtitle-good2.vtt @@ -0,0 +1,8 @@ +WEBVTT + +00:01.000 --> 00:04.000 +Subtitle good 2. + +00:05.000 --> 00:09.000 +- It will perforate your stomach. +- You could die. \ No newline at end of file diff --git a/packages/tests/fixtures/thumbnail-playlist.jpg b/packages/tests/fixtures/thumbnail-playlist.jpg new file mode 100644 index 000000000..12de5817b Binary files /dev/null and b/packages/tests/fixtures/thumbnail-playlist.jpg differ diff --git a/packages/tests/fixtures/video-720p.torrent b/packages/tests/fixtures/video-720p.torrent new file mode 100644 index 000000000..64bfd5220 Binary files /dev/null and b/packages/tests/fixtures/video-720p.torrent differ diff --git a/packages/tests/fixtures/video_import_preview.jpg b/packages/tests/fixtures/video_import_preview.jpg new file mode 100644 index 000000000..a98da178f Binary files /dev/null and b/packages/tests/fixtures/video_import_preview.jpg differ diff --git a/packages/tests/fixtures/video_import_preview_yt_dlp.jpg b/packages/tests/fixtures/video_import_preview_yt_dlp.jpg new file mode 100644 index 000000000..9e8833bf9 Binary files /dev/null and b/packages/tests/fixtures/video_import_preview_yt_dlp.jpg differ diff --git a/packages/tests/fixtures/video_import_thumbnail.jpg b/packages/tests/fixtures/video_import_thumbnail.jpg new file mode 100644 index 000000000..9ee1bc382 Binary files /dev/null and b/packages/tests/fixtures/video_import_thumbnail.jpg differ diff --git a/packages/tests/fixtures/video_import_thumbnail_yt_dlp.jpg b/packages/tests/fixtures/video_import_thumbnail_yt_dlp.jpg new file mode 100644 index 000000000..a10e07207 Binary files /dev/null and b/packages/tests/fixtures/video_import_thumbnail_yt_dlp.jpg differ diff --git a/packages/tests/fixtures/video_short.avi b/packages/tests/fixtures/video_short.avi new file mode 100644 index 000000000..88979cab2 Binary files /dev/null and b/packages/tests/fixtures/video_short.avi differ diff --git a/packages/tests/fixtures/video_short.mkv b/packages/tests/fixtures/video_short.mkv new file mode 100644 index 000000000..a67f4f806 Binary files /dev/null and b/packages/tests/fixtures/video_short.mkv differ diff --git a/packages/tests/fixtures/video_short.mp4 b/packages/tests/fixtures/video_short.mp4 new file mode 100644 index 000000000..35678362b Binary files /dev/null and b/packages/tests/fixtures/video_short.mp4 differ diff --git a/packages/tests/fixtures/video_short.mp4.jpg b/packages/tests/fixtures/video_short.mp4.jpg new file mode 100644 index 000000000..7ac29122c Binary files /dev/null and b/packages/tests/fixtures/video_short.mp4.jpg differ diff --git a/packages/tests/fixtures/video_short.ogv b/packages/tests/fixtures/video_short.ogv new file mode 100644 index 000000000..9e253da82 Binary files /dev/null and b/packages/tests/fixtures/video_short.ogv differ diff --git a/packages/tests/fixtures/video_short.ogv.jpg b/packages/tests/fixtures/video_short.ogv.jpg new file mode 100644 index 000000000..5bc63969b Binary files /dev/null and b/packages/tests/fixtures/video_short.ogv.jpg differ diff --git a/packages/tests/fixtures/video_short.webm b/packages/tests/fixtures/video_short.webm new file mode 100644 index 000000000..bf4b0ab6c Binary files /dev/null and b/packages/tests/fixtures/video_short.webm differ diff --git a/packages/tests/fixtures/video_short.webm.jpg b/packages/tests/fixtures/video_short.webm.jpg new file mode 100644 index 000000000..7ac29122c Binary files /dev/null and b/packages/tests/fixtures/video_short.webm.jpg differ diff --git a/packages/tests/fixtures/video_short1-preview.webm.jpg b/packages/tests/fixtures/video_short1-preview.webm.jpg new file mode 100644 index 000000000..15454942d Binary files /dev/null and b/packages/tests/fixtures/video_short1-preview.webm.jpg differ diff --git a/packages/tests/fixtures/video_short1.webm b/packages/tests/fixtures/video_short1.webm new file mode 100644 index 000000000..70ac0c644 Binary files /dev/null and b/packages/tests/fixtures/video_short1.webm differ diff --git a/packages/tests/fixtures/video_short1.webm.jpg b/packages/tests/fixtures/video_short1.webm.jpg new file mode 100644 index 000000000..b2740d73d Binary files /dev/null and b/packages/tests/fixtures/video_short1.webm.jpg differ diff --git a/packages/tests/fixtures/video_short2.webm b/packages/tests/fixtures/video_short2.webm new file mode 100644 index 000000000..13d72dff7 Binary files /dev/null and b/packages/tests/fixtures/video_short2.webm differ diff --git a/packages/tests/fixtures/video_short2.webm.jpg b/packages/tests/fixtures/video_short2.webm.jpg new file mode 100644 index 000000000..afe476c7f Binary files /dev/null and b/packages/tests/fixtures/video_short2.webm.jpg differ diff --git a/packages/tests/fixtures/video_short3.webm b/packages/tests/fixtures/video_short3.webm new file mode 100644 index 000000000..cde5dcd58 Binary files /dev/null and b/packages/tests/fixtures/video_short3.webm differ diff --git a/packages/tests/fixtures/video_short3.webm.jpg b/packages/tests/fixtures/video_short3.webm.jpg new file mode 100644 index 000000000..b572f676e Binary files /dev/null and b/packages/tests/fixtures/video_short3.webm.jpg differ diff --git a/packages/tests/fixtures/video_short_0p.mp4 b/packages/tests/fixtures/video_short_0p.mp4 new file mode 100644 index 000000000..2069a49b8 Binary files /dev/null and b/packages/tests/fixtures/video_short_0p.mp4 differ diff --git a/packages/tests/fixtures/video_short_144p.m3u8 b/packages/tests/fixtures/video_short_144p.m3u8 new file mode 100644 index 000000000..96568625b --- /dev/null +++ b/packages/tests/fixtures/video_short_144p.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:4 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-MAP:URI="3dd13e27-1ae1-441c-9b77-48c6b95603be-144-fragmented.mp4",BYTERANGE="1375@0" +#EXTINF:4.000000, +#EXT-X-BYTERANGE:10518@1375 +3dd13e27-1ae1-441c-9b77-48c6b95603be-144-fragmented.mp4 +#EXTINF:1.000000, +#EXT-X-BYTERANGE:3741@11893 +3dd13e27-1ae1-441c-9b77-48c6b95603be-144-fragmented.mp4 +#EXT-X-ENDLIST diff --git a/packages/tests/fixtures/video_short_144p.mp4 b/packages/tests/fixtures/video_short_144p.mp4 new file mode 100644 index 000000000..047d43c17 Binary files /dev/null and b/packages/tests/fixtures/video_short_144p.mp4 differ diff --git a/packages/tests/fixtures/video_short_240p.m3u8 b/packages/tests/fixtures/video_short_240p.m3u8 new file mode 100644 index 000000000..96568625b --- /dev/null +++ b/packages/tests/fixtures/video_short_240p.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:4 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-MAP:URI="3dd13e27-1ae1-441c-9b77-48c6b95603be-144-fragmented.mp4",BYTERANGE="1375@0" +#EXTINF:4.000000, +#EXT-X-BYTERANGE:10518@1375 +3dd13e27-1ae1-441c-9b77-48c6b95603be-144-fragmented.mp4 +#EXTINF:1.000000, +#EXT-X-BYTERANGE:3741@11893 +3dd13e27-1ae1-441c-9b77-48c6b95603be-144-fragmented.mp4 +#EXT-X-ENDLIST diff --git a/packages/tests/fixtures/video_short_240p.mp4 b/packages/tests/fixtures/video_short_240p.mp4 new file mode 100644 index 000000000..46609e81a Binary files /dev/null and b/packages/tests/fixtures/video_short_240p.mp4 differ diff --git a/packages/tests/fixtures/video_short_360p.m3u8 b/packages/tests/fixtures/video_short_360p.m3u8 new file mode 100644 index 000000000..f7072dc6d --- /dev/null +++ b/packages/tests/fixtures/video_short_360p.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:4 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-MAP:URI="05c40acd-3e94-4d25-ade8-97f7ff2cf0ac-360-fragmented.mp4",BYTERANGE="1376@0" +#EXTINF:4.000000, +#EXT-X-BYTERANGE:19987@1376 +05c40acd-3e94-4d25-ade8-97f7ff2cf0ac-360-fragmented.mp4 +#EXTINF:1.000000, +#EXT-X-BYTERANGE:9147@21363 +05c40acd-3e94-4d25-ade8-97f7ff2cf0ac-360-fragmented.mp4 +#EXT-X-ENDLIST diff --git a/packages/tests/fixtures/video_short_360p.mp4 b/packages/tests/fixtures/video_short_360p.mp4 new file mode 100644 index 000000000..7a8189bbc Binary files /dev/null and b/packages/tests/fixtures/video_short_360p.mp4 differ diff --git a/packages/tests/fixtures/video_short_480.webm b/packages/tests/fixtures/video_short_480.webm new file mode 100644 index 000000000..3145105e1 Binary files /dev/null and b/packages/tests/fixtures/video_short_480.webm differ diff --git a/packages/tests/fixtures/video_short_480p.m3u8 b/packages/tests/fixtures/video_short_480p.m3u8 new file mode 100644 index 000000000..5ff30dfa7 --- /dev/null +++ b/packages/tests/fixtures/video_short_480p.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:4 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-MAP:URI="f9377e69-d8f2-4de8-8087-ddbca6629829-480-fragmented.mp4",BYTERANGE="1376@0" +#EXTINF:4.000000, +#EXT-X-BYTERANGE:26042@1376 +f9377e69-d8f2-4de8-8087-ddbca6629829-480-fragmented.mp4 +#EXTINF:1.000000, +#EXT-X-BYTERANGE:12353@27418 +f9377e69-d8f2-4de8-8087-ddbca6629829-480-fragmented.mp4 +#EXT-X-ENDLIST diff --git a/packages/tests/fixtures/video_short_480p.mp4 b/packages/tests/fixtures/video_short_480p.mp4 new file mode 100644 index 000000000..e05b58b6b Binary files /dev/null and b/packages/tests/fixtures/video_short_480p.mp4 differ diff --git a/packages/tests/fixtures/video_short_4k.mp4 b/packages/tests/fixtures/video_short_4k.mp4 new file mode 100644 index 000000000..402479743 Binary files /dev/null and b/packages/tests/fixtures/video_short_4k.mp4 differ diff --git a/packages/tests/fixtures/video_short_720p.m3u8 b/packages/tests/fixtures/video_short_720p.m3u8 new file mode 100644 index 000000000..7cee94032 --- /dev/null +++ b/packages/tests/fixtures/video_short_720p.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:4 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-MAP:URI="c1014aa4-d1f4-4b66-927b-c23d283fcae0-720-fragmented.mp4",BYTERANGE="1356@0" +#EXTINF:4.000000, +#EXT-X-BYTERANGE:39260@1356 +c1014aa4-d1f4-4b66-927b-c23d283fcae0-720-fragmented.mp4 +#EXTINF:1.000000, +#EXT-X-BYTERANGE:18493@40616 +c1014aa4-d1f4-4b66-927b-c23d283fcae0-720-fragmented.mp4 +#EXT-X-ENDLIST diff --git a/packages/tests/fixtures/video_short_720p.mp4 b/packages/tests/fixtures/video_short_720p.mp4 new file mode 100644 index 000000000..35e8f69a7 Binary files /dev/null and b/packages/tests/fixtures/video_short_720p.mp4 differ diff --git a/packages/tests/fixtures/video_short_fake.webm b/packages/tests/fixtures/video_short_fake.webm new file mode 100644 index 000000000..d85290ae5 --- /dev/null +++ b/packages/tests/fixtures/video_short_fake.webm @@ -0,0 +1 @@ +this is a fake video mouahahah diff --git a/packages/tests/fixtures/video_short_mp3_256k.mp4 b/packages/tests/fixtures/video_short_mp3_256k.mp4 new file mode 100644 index 000000000..4c1c7b45e Binary files /dev/null and b/packages/tests/fixtures/video_short_mp3_256k.mp4 differ diff --git a/packages/tests/fixtures/video_short_no_audio.mp4 b/packages/tests/fixtures/video_short_no_audio.mp4 new file mode 100644 index 000000000..329d20fba Binary files /dev/null and b/packages/tests/fixtures/video_short_no_audio.mp4 differ diff --git a/packages/tests/fixtures/video_very_long_10p.mp4 b/packages/tests/fixtures/video_very_long_10p.mp4 new file mode 100644 index 000000000..852297933 Binary files /dev/null and b/packages/tests/fixtures/video_very_long_10p.mp4 differ diff --git a/packages/tests/fixtures/video_very_short_240p.mp4 b/packages/tests/fixtures/video_very_short_240p.mp4 new file mode 100644 index 000000000..95b6be92a Binary files /dev/null and b/packages/tests/fixtures/video_very_short_240p.mp4 differ diff --git a/packages/tests/package.json b/packages/tests/package.json new file mode 100644 index 000000000..02882ebc7 --- /dev/null +++ b/packages/tests/package.json @@ -0,0 +1,12 @@ +{ + "name": "@peertube/tests", + "private": true, + "version": "0.0.0", + "type": "module", + "devDependencies": {}, + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "dependencies": {} +} diff --git a/packages/tests/src/api/activitypub/cleaner.ts b/packages/tests/src/api/activitypub/cleaner.ts new file mode 100644 index 000000000..4476aea85 --- /dev/null +++ b/packages/tests/src/api/activitypub/cleaner.ts @@ -0,0 +1,342 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { wait } from '@peertube/peertube-core-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test AP cleaner', function () { + let servers: PeerTubeServer[] = [] + const sqlCommands: SQLCommand[] = [] + + let videoUUID1: string + let videoUUID2: string + let videoUUID3: string + + let videoUUIDs: string[] + + before(async function () { + this.timeout(120000) + + const config = { + federation: { + videos: { cleanup_remote_interactions: true } + } + } + servers = await createMultipleServers(3, config) + + // Get the access tokens + await setAccessTokensToServers(servers) + + await Promise.all([ + doubleFollow(servers[0], servers[1]), + doubleFollow(servers[1], servers[2]), + doubleFollow(servers[0], servers[2]) + ]) + + // Update 1 local share, check 6 shares + + // Create 1 comment per video + // Update 1 remote URL and 1 local URL on + + videoUUID1 = (await servers[0].videos.quickUpload({ name: 'server 1' })).uuid + videoUUID2 = (await servers[1].videos.quickUpload({ name: 'server 2' })).uuid + videoUUID3 = (await servers[2].videos.quickUpload({ name: 'server 3' })).uuid + + videoUUIDs = [ videoUUID1, videoUUID2, videoUUID3 ] + + await waitJobs(servers) + + for (const server of servers) { + for (const uuid of videoUUIDs) { + await server.videos.rate({ id: uuid, rating: 'like' }) + await server.comments.createThread({ videoId: uuid, text: 'comment' }) + } + + sqlCommands.push(new SQLCommand(server)) + } + + await waitJobs(servers) + }) + + it('Should have the correct likes', async function () { + for (const server of servers) { + for (const uuid of videoUUIDs) { + const video = await server.videos.get({ id: uuid }) + + expect(video.likes).to.equal(3) + expect(video.dislikes).to.equal(0) + } + } + }) + + it('Should destroy server 3 internal likes and correctly clean them', async function () { + this.timeout(20000) + + await sqlCommands[2].deleteAll('accountVideoRate') + for (const uuid of videoUUIDs) { + await sqlCommands[2].setVideoField(uuid, 'likes', '0') + } + + await wait(5000) + await waitJobs(servers) + + // Updated rates of my video + { + const video = await servers[0].videos.get({ id: videoUUID1 }) + expect(video.likes).to.equal(2) + expect(video.dislikes).to.equal(0) + } + + // Did not update rates of a remote video + { + const video = await servers[0].videos.get({ id: videoUUID2 }) + expect(video.likes).to.equal(3) + expect(video.dislikes).to.equal(0) + } + }) + + it('Should update rates to dislikes', async function () { + this.timeout(20000) + + for (const server of servers) { + for (const uuid of videoUUIDs) { + await server.videos.rate({ id: uuid, rating: 'dislike' }) + } + } + + await waitJobs(servers) + + for (const server of servers) { + for (const uuid of videoUUIDs) { + const video = await server.videos.get({ id: uuid }) + expect(video.likes).to.equal(0) + expect(video.dislikes).to.equal(3) + } + } + }) + + it('Should destroy server 3 internal dislikes and correctly clean them', async function () { + this.timeout(20000) + + await sqlCommands[2].deleteAll('accountVideoRate') + + for (const uuid of videoUUIDs) { + await sqlCommands[2].setVideoField(uuid, 'dislikes', '0') + } + + await wait(5000) + await waitJobs(servers) + + // Updated rates of my video + { + const video = await servers[0].videos.get({ id: videoUUID1 }) + expect(video.likes).to.equal(0) + expect(video.dislikes).to.equal(2) + } + + // Did not update rates of a remote video + { + const video = await servers[0].videos.get({ id: videoUUID2 }) + expect(video.likes).to.equal(0) + expect(video.dislikes).to.equal(3) + } + }) + + it('Should destroy server 3 internal shares and correctly clean them', async function () { + this.timeout(20000) + + const preCount = await sqlCommands[0].getVideoShareCount() + expect(preCount).to.equal(6) + + await sqlCommands[2].deleteAll('videoShare') + await wait(5000) + await waitJobs(servers) + + // Still 6 because we don't have remote shares on local videos + const postCount = await sqlCommands[0].getVideoShareCount() + expect(postCount).to.equal(6) + }) + + it('Should destroy server 3 internal comments and correctly clean them', async function () { + this.timeout(20000) + + { + const { total } = await servers[0].comments.listThreads({ videoId: videoUUID1 }) + expect(total).to.equal(3) + } + + await sqlCommands[2].deleteAll('videoComment') + + await wait(5000) + await waitJobs(servers) + + { + const { total } = await servers[0].comments.listThreads({ videoId: videoUUID1 }) + expect(total).to.equal(2) + } + }) + + it('Should correctly update rate URLs', async function () { + this.timeout(30000) + + async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') { + const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` + + `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'` + const res = await sqlCommands[0].selectQuery<{ url: string }>(query) + + for (const rate of res) { + const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`) + expect(rate.url).to.match(matcher) + } + } + + async function checkLocal () { + const startsWith = 'http://' + servers[0].host + '%' + // On local videos + await check(startsWith, servers[0].url, '', 'false') + // On remote videos + await check(startsWith, servers[0].url, '', 'true') + } + + async function checkRemote (suffix: string) { + const startsWith = 'http://' + servers[1].host + '%' + // On local videos + await check(startsWith, servers[1].url, suffix, 'false') + // On remote videos, we should not update URLs so no suffix + await check(startsWith, servers[1].url, '', 'true') + } + + await checkLocal() + await checkRemote('') + + { + const query = `UPDATE "accountVideoRate" SET url = url || 'stan'` + await sqlCommands[1].updateQuery(query) + + await wait(5000) + await waitJobs(servers) + } + + await checkLocal() + await checkRemote('stan') + }) + + it('Should correctly update comment URLs', async function () { + this.timeout(30000) + + async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') { + const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` + + `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'` + + const res = await sqlCommands[0].selectQuery<{ url: string, videoUUID: string }>(query) + + for (const comment of res) { + const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`) + expect(comment.url).to.match(matcher) + } + } + + async function checkLocal () { + const startsWith = 'http://' + servers[0].host + '%' + // On local videos + await check(startsWith, servers[0].url, '', 'false') + // On remote videos + await check(startsWith, servers[0].url, '', 'true') + } + + async function checkRemote (suffix: string) { + const startsWith = 'http://' + servers[1].host + '%' + // On local videos + await check(startsWith, servers[1].url, suffix, 'false') + // On remote videos, we should not update URLs so no suffix + await check(startsWith, servers[1].url, '', 'true') + } + + { + const query = `UPDATE "videoComment" SET url = url || 'kyle'` + await sqlCommands[1].updateQuery(query) + + await wait(5000) + await waitJobs(servers) + } + + await checkLocal() + await checkRemote('kyle') + }) + + it('Should remove unavailable remote resources', async function () { + this.timeout(240000) + + async function expectNotDeleted () { + { + const video = await servers[0].videos.get({ id: uuid }) + + expect(video.likes).to.equal(3) + expect(video.dislikes).to.equal(0) + } + + { + const { total } = await servers[0].comments.listThreads({ videoId: uuid }) + expect(total).to.equal(3) + } + } + + async function expectDeleted () { + { + const video = await servers[0].videos.get({ id: uuid }) + + expect(video.likes).to.equal(2) + expect(video.dislikes).to.equal(0) + } + + { + const { total } = await servers[0].comments.listThreads({ videoId: uuid }) + expect(total).to.equal(2) + } + } + + const uuid = (await servers[0].videos.quickUpload({ name: 'server 1 video 2' })).uuid + + await waitJobs(servers) + + for (const server of servers) { + await server.videos.rate({ id: uuid, rating: 'like' }) + await server.comments.createThread({ videoId: uuid, text: 'comment' }) + } + + await waitJobs(servers) + + await expectNotDeleted() + + await servers[1].kill() + + await wait(5000) + await expectNotDeleted() + + let continueWhile = true + + do { + try { + await expectDeleted() + continueWhile = false + } catch { + } + } while (continueWhile) + }) + + after(async function () { + for (const sql of sqlCommands) { + await sql.cleanup() + } + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/activitypub/client.ts b/packages/tests/src/api/activitypub/client.ts new file mode 100644 index 000000000..fb9575d31 --- /dev/null +++ b/packages/tests/src/api/activitypub/client.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { processViewersStats } from '@tests/shared/views.js' +import { HttpStatusCode, VideoPlaylistPrivacy, WatchActionObject } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeActivityPubGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test activitypub', function () { + let servers: PeerTubeServer[] = [] + let video: { id: number, uuid: string, shortUUID: string } + let playlist: { id: number, uuid: string, shortUUID: string } + + async function testAccount (path: string) { + const res = await makeActivityPubGetRequest(servers[0].url, path) + const object = res.body + + expect(object.type).to.equal('Person') + expect(object.id).to.equal(servers[0].url + '/accounts/root') + expect(object.name).to.equal('root') + expect(object.preferredUsername).to.equal('root') + } + + async function testChannel (path: string) { + const res = await makeActivityPubGetRequest(servers[0].url, path) + const object = res.body + + expect(object.type).to.equal('Group') + expect(object.id).to.equal(servers[0].url + '/video-channels/root_channel') + expect(object.name).to.equal('Main root channel') + expect(object.preferredUsername).to.equal('root_channel') + } + + async function testVideo (path: string) { + const res = await makeActivityPubGetRequest(servers[0].url, path) + const object = res.body + + expect(object.type).to.equal('Video') + expect(object.id).to.equal(servers[0].url + '/videos/watch/' + video.uuid) + expect(object.name).to.equal('video') + } + + async function testPlaylist (path: string) { + const res = await makeActivityPubGetRequest(servers[0].url, path) + const object = res.body + + expect(object.type).to.equal('Playlist') + expect(object.id).to.equal(servers[0].url + '/video-playlists/' + playlist.uuid) + expect(object.name).to.equal('playlist') + } + + before(async function () { + this.timeout(30000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + { + video = await servers[0].videos.quickUpload({ name: 'video' }) + } + + { + const attributes = { displayName: 'playlist', privacy: VideoPlaylistPrivacy.PUBLIC, videoChannelId: servers[0].store.channel.id } + playlist = await servers[0].playlists.create({ attributes }) + } + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should return the account object', async function () { + await testAccount('/accounts/root') + await testAccount('/a/root') + }) + + it('Should return the channel object', async function () { + await testChannel('/video-channels/root_channel') + await testChannel('/c/root_channel') + }) + + it('Should return the video object', async function () { + await testVideo('/videos/watch/' + video.id) + await testVideo('/videos/watch/' + video.uuid) + await testVideo('/videos/watch/' + video.shortUUID) + await testVideo('/w/' + video.id) + await testVideo('/w/' + video.uuid) + await testVideo('/w/' + video.shortUUID) + }) + + it('Should return the playlist object', async function () { + await testPlaylist('/video-playlists/' + playlist.id) + await testPlaylist('/video-playlists/' + playlist.uuid) + await testPlaylist('/video-playlists/' + playlist.shortUUID) + await testPlaylist('/w/p/' + playlist.id) + await testPlaylist('/w/p/' + playlist.uuid) + await testPlaylist('/w/p/' + playlist.shortUUID) + await testPlaylist('/videos/watch/playlist/' + playlist.id) + await testPlaylist('/videos/watch/playlist/' + playlist.uuid) + await testPlaylist('/videos/watch/playlist/' + playlist.shortUUID) + }) + + it('Should redirect to the origin video object', async function () { + const res = await makeActivityPubGetRequest(servers[1].url, '/videos/watch/' + video.uuid, HttpStatusCode.FOUND_302) + + expect(res.header.location).to.equal(servers[0].url + '/videos/watch/' + video.uuid) + }) + + it('Should return the watch action', async function () { + this.timeout(50000) + + await servers[0].views.simulateViewer({ id: video.uuid, currentTimes: [ 0, 2 ] }) + await processViewersStats(servers) + + const res = await makeActivityPubGetRequest(servers[0].url, '/videos/local-viewer/1', HttpStatusCode.OK_200) + + const object: WatchActionObject = res.body + expect(object.type).to.equal('WatchAction') + expect(object.duration).to.equal('PT2S') + expect(object.actionStatus).to.equal('CompletedActionStatus') + expect(object.watchSections).to.have.lengthOf(1) + expect(object.watchSections[0].startTimestamp).to.equal(0) + expect(object.watchSections[0].endTimestamp).to.equal(2) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/activitypub/fetch.ts b/packages/tests/src/api/activitypub/fetch.ts new file mode 100644 index 000000000..c7f5288cc --- /dev/null +++ b/packages/tests/src/api/activitypub/fetch.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test ActivityPub fetcher', function () { + let servers: PeerTubeServer[] + let sqlCommandServer1: SQLCommand + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + + const user = { username: 'user1', password: 'password' } + for (const server of servers) { + await server.users.create({ username: user.username, password: user.password }) + } + + const userAccessToken = await servers[0].login.getAccessToken(user) + + await servers[0].videos.upload({ attributes: { name: 'video root' } }) + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'bad video root' } }) + await servers[0].videos.upload({ token: userAccessToken, attributes: { name: 'video user' } }) + + sqlCommandServer1 = new SQLCommand(servers[0]) + + { + const to = servers[0].url + '/accounts/user1' + const value = servers[1].url + '/accounts/user1' + await sqlCommandServer1.setActorField(to, 'url', value) + } + + { + const value = servers[2].url + '/videos/watch/' + uuid + await sqlCommandServer1.setVideoField(uuid, 'url', value) + } + }) + + it('Should add only the video with a valid actor URL', async function () { + this.timeout(60000) + + await doubleFollow(servers[0], servers[1]) + await waitJobs(servers) + + { + const { total, data } = await servers[0].videos.list({ sort: 'createdAt' }) + + expect(total).to.equal(3) + expect(data[0].name).to.equal('video root') + expect(data[1].name).to.equal('bad video root') + expect(data[2].name).to.equal('video user') + } + + { + const { total, data } = await servers[1].videos.list({ sort: 'createdAt' }) + + expect(total).to.equal(1) + expect(data[0].name).to.equal('video root') + } + }) + + after(async function () { + this.timeout(20000) + + await sqlCommandServer1.cleanup() + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/activitypub/index.ts b/packages/tests/src/api/activitypub/index.ts new file mode 100644 index 000000000..ef4f1aafb --- /dev/null +++ b/packages/tests/src/api/activitypub/index.ts @@ -0,0 +1,5 @@ +import './cleaner.js' +import './client.js' +import './fetch.js' +import './refresher.js' +import './security.js' diff --git a/packages/tests/src/api/activitypub/refresher.ts b/packages/tests/src/api/activitypub/refresher.ts new file mode 100644 index 000000000..90aa1a5ad --- /dev/null +++ b/packages/tests/src/api/activitypub/refresher.ts @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { SQLCommand } from '@tests/shared/sql-command.js' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoPlaylistPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + killallServers, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test AP refresher', function () { + let servers: PeerTubeServer[] = [] + let sqlCommandServer2: SQLCommand + let videoUUID1: string + let videoUUID2: string + let videoUUID3: string + let playlistUUID1: string + let playlistUUID2: string + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + for (const server of servers) { + await server.config.disableTranscoding() + } + + { + videoUUID1 = (await servers[1].videos.quickUpload({ name: 'video1' })).uuid + videoUUID2 = (await servers[1].videos.quickUpload({ name: 'video2' })).uuid + videoUUID3 = (await servers[1].videos.quickUpload({ name: 'video3' })).uuid + } + + { + const token1 = await servers[1].users.generateUserAndToken('user1') + await servers[1].videos.upload({ token: token1, attributes: { name: 'video4' } }) + + const token2 = await servers[1].users.generateUserAndToken('user2') + await servers[1].videos.upload({ token: token2, attributes: { name: 'video5' } }) + } + + { + const attributes = { displayName: 'playlist1', privacy: VideoPlaylistPrivacy.PUBLIC, videoChannelId: servers[1].store.channel.id } + const created = await servers[1].playlists.create({ attributes }) + playlistUUID1 = created.uuid + } + + { + const attributes = { displayName: 'playlist2', privacy: VideoPlaylistPrivacy.PUBLIC, videoChannelId: servers[1].store.channel.id } + const created = await servers[1].playlists.create({ attributes }) + playlistUUID2 = created.uuid + } + + await doubleFollow(servers[0], servers[1]) + + sqlCommandServer2 = new SQLCommand(servers[1]) + }) + + describe('Videos refresher', function () { + + it('Should remove a deleted remote video', async function () { + this.timeout(60000) + + await wait(10000) + + // Change UUID so the remote server returns a 404 + await sqlCommandServer2.setVideoField(videoUUID1, 'uuid', '304afe4f-39f9-4d49-8ed7-ac57b86b174f') + + await servers[0].videos.get({ id: videoUUID1 }) + await servers[0].videos.get({ id: videoUUID2 }) + + await waitJobs(servers) + + await servers[0].videos.get({ id: videoUUID1, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await servers[0].videos.get({ id: videoUUID2 }) + }) + + it('Should not update a remote video if the remote instance is down', async function () { + this.timeout(70000) + + await killallServers([ servers[1] ]) + + await sqlCommandServer2.setVideoField(videoUUID3, 'uuid', '304afe4f-39f9-4d49-8ed7-ac57b86b174e') + + // Video will need a refresh + await wait(10000) + + await servers[0].videos.get({ id: videoUUID3 }) + // The refresh should fail + await waitJobs([ servers[0] ]) + + await servers[1].run() + + await servers[0].videos.get({ id: videoUUID3 }) + }) + }) + + describe('Actors refresher', function () { + + it('Should remove a deleted actor', async function () { + this.timeout(60000) + + const command = servers[0].accounts + + await wait(10000) + + // Change actor name so the remote server returns a 404 + const to = servers[1].url + '/accounts/user2' + await sqlCommandServer2.setActorField(to, 'preferredUsername', 'toto') + + await command.get({ accountName: 'user1@' + servers[1].host }) + await command.get({ accountName: 'user2@' + servers[1].host }) + + await waitJobs(servers) + + await command.get({ accountName: 'user1@' + servers[1].host, expectedStatus: HttpStatusCode.OK_200 }) + await command.get({ accountName: 'user2@' + servers[1].host, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('Playlist refresher', function () { + + it('Should remove a deleted playlist', async function () { + this.timeout(60000) + + await wait(10000) + + // Change UUID so the remote server returns a 404 + await sqlCommandServer2.setPlaylistField(playlistUUID2, 'uuid', '304afe4f-39f9-4d49-8ed7-ac57b86b178e') + + await servers[0].playlists.get({ playlistId: playlistUUID1 }) + await servers[0].playlists.get({ playlistId: playlistUUID2 }) + + await waitJobs(servers) + + await servers[0].playlists.get({ playlistId: playlistUUID1, expectedStatus: HttpStatusCode.OK_200 }) + await servers[0].playlists.get({ playlistId: playlistUUID2, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + after(async function () { + await sqlCommandServer2.cleanup() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/activitypub/security.ts b/packages/tests/src/api/activitypub/security.ts new file mode 100644 index 000000000..d9649de50 --- /dev/null +++ b/packages/tests/src/api/activitypub/security.ts @@ -0,0 +1,331 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { PeerTubeServer, cleanupTests, createMultipleServers, killallServers } from '@peertube/peertube-server-commands' +import { + activityPubContextify, + buildGlobalHTTPHeaders, + signAndContextify +} from '@peertube/peertube-server/server/helpers/activity-pub-utils.js' +import { buildDigest } from '@peertube/peertube-server/server/helpers/peertube-crypto.js' +import { ACTIVITY_PUB, HTTP_SIGNATURE } from '@peertube/peertube-server/server/initializers/constants.js' +import { makePOSTAPRequest } from '@tests/shared/requests.js' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { expect } from 'chai' +import { readJsonSync } from 'fs-extra/esm' + +function fakeFilter () { + return (data: any) => Promise.resolve(data) +} + +function setKeysOfServer (onServer: SQLCommand, ofServerUrl: string, publicKey: string, privateKey: string) { + const url = ofServerUrl + '/accounts/peertube' + + return Promise.all([ + onServer.setActorField(url, 'publicKey', publicKey), + onServer.setActorField(url, 'privateKey', privateKey) + ]) +} + +function setUpdatedAtOfServer (onServer: SQLCommand, ofServerUrl: string, updatedAt: string) { + const url = ofServerUrl + '/accounts/peertube' + + return Promise.all([ + onServer.setActorField(url, 'createdAt', updatedAt), + onServer.setActorField(url, 'updatedAt', updatedAt) + ]) +} + +function getAnnounceWithoutContext (server: PeerTubeServer) { + const json = readJsonSync(buildAbsoluteFixturePath('./ap-json/peertube/announce-without-context.json')) + const result: typeof json = {} + + for (const key of Object.keys(json)) { + if (Array.isArray(json[key])) { + result[key] = json[key].map(v => v.replace(':9002', `:${server.port}`)) + } else { + result[key] = json[key].replace(':9002', `:${server.port}`) + } + } + + return result +} + +async function makeFollowRequest (to: { url: string }, by: { url: string, privateKey }) { + const follow = { + type: 'Follow', + id: by.url + '/' + new Date().getTime(), + actor: by.url, + object: to.url + } + + const body = await activityPubContextify(follow, 'Follow', fakeFilter()) + + const httpSignature = { + algorithm: HTTP_SIGNATURE.ALGORITHM, + authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME, + keyId: by.url, + key: by.privateKey, + headers: HTTP_SIGNATURE.HEADERS_TO_SIGN_WITH_PAYLOAD + } + const headers = { + 'digest': buildDigest(body), + 'content-type': 'application/activity+json', + 'accept': ACTIVITY_PUB.ACCEPT_HEADER + } + + return makePOSTAPRequest(to.url + '/inbox', body, httpSignature, headers) +} + +describe('Test ActivityPub security', function () { + let servers: PeerTubeServer[] + let sqlCommands: SQLCommand[] = [] + + let url: string + + const keys = readJsonSync(buildAbsoluteFixturePath('./ap-json/peertube/keys.json')) + const invalidKeys = readJsonSync(buildAbsoluteFixturePath('./ap-json/peertube/invalid-keys.json')) + const baseHttpSignature = () => ({ + algorithm: HTTP_SIGNATURE.ALGORITHM, + authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME, + keyId: 'acct:peertube@' + servers[1].host, + key: keys.privateKey, + headers: HTTP_SIGNATURE.HEADERS_TO_SIGN_WITH_PAYLOAD + }) + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(3) + + sqlCommands = servers.map(s => new SQLCommand(s)) + + url = servers[0].url + '/inbox' + + await setKeysOfServer(sqlCommands[0], servers[1].url, keys.publicKey, null) + await setKeysOfServer(sqlCommands[1], servers[1].url, keys.publicKey, keys.privateKey) + + const to = { url: servers[0].url + '/accounts/peertube' } + const by = { url: servers[1].url + '/accounts/peertube', privateKey: keys.privateKey } + await makeFollowRequest(to, by) + }) + + describe('When checking HTTP signature', function () { + + it('Should fail with an invalid digest', async function () { + const body = await activityPubContextify(getAnnounceWithoutContext(servers[1]), 'Announce', fakeFilter()) + const headers = { + Digest: buildDigest({ hello: 'coucou' }) + } + + try { + await makePOSTAPRequest(url, body, baseHttpSignature(), headers) + expect(true, 'Did not throw').to.be.false + } catch (err) { + expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) + } + }) + + it('Should fail with an invalid date', async function () { + const body = await activityPubContextify(getAnnounceWithoutContext(servers[1]), 'Announce', fakeFilter()) + const headers = buildGlobalHTTPHeaders(body) + headers['date'] = 'Wed, 21 Oct 2015 07:28:00 GMT' + + try { + await makePOSTAPRequest(url, body, baseHttpSignature(), headers) + expect(true, 'Did not throw').to.be.false + } catch (err) { + expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) + } + }) + + it('Should fail with bad keys', async function () { + await setKeysOfServer(sqlCommands[0], servers[1].url, invalidKeys.publicKey, invalidKeys.privateKey) + await setKeysOfServer(sqlCommands[1], servers[1].url, invalidKeys.publicKey, invalidKeys.privateKey) + + const body = await activityPubContextify(getAnnounceWithoutContext(servers[1]), 'Announce', fakeFilter()) + const headers = buildGlobalHTTPHeaders(body) + + try { + await makePOSTAPRequest(url, body, baseHttpSignature(), headers) + expect(true, 'Did not throw').to.be.false + } catch (err) { + expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) + } + }) + + it('Should reject requests without appropriate signed headers', async function () { + await setKeysOfServer(sqlCommands[0], servers[1].url, keys.publicKey, keys.privateKey) + await setKeysOfServer(sqlCommands[1], servers[1].url, keys.publicKey, keys.privateKey) + + const body = await activityPubContextify(getAnnounceWithoutContext(servers[1]), 'Announce', fakeFilter()) + const headers = buildGlobalHTTPHeaders(body) + + const signatureOptions = baseHttpSignature() + const badHeadersMatrix = [ + [ '(request-target)', 'date', 'digest' ], + [ 'host', 'date', 'digest' ], + [ '(request-target)', 'host', 'digest' ] + ] + + for (const badHeaders of badHeadersMatrix) { + signatureOptions.headers = badHeaders + + try { + await makePOSTAPRequest(url, body, signatureOptions, headers) + expect(true, 'Did not throw').to.be.false + } catch (err) { + expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) + } + } + }) + + it('Should succeed with a valid HTTP signature draft 11 (without date but with (created))', async function () { + const body = await activityPubContextify(getAnnounceWithoutContext(servers[1]), 'Announce', fakeFilter()) + const headers = buildGlobalHTTPHeaders(body) + + const signatureOptions = baseHttpSignature() + signatureOptions.headers = [ '(request-target)', '(created)', 'host', 'digest' ] + + const { statusCode } = await makePOSTAPRequest(url, body, signatureOptions, headers) + expect(statusCode).to.equal(HttpStatusCode.NO_CONTENT_204) + }) + + it('Should succeed with a valid HTTP signature', async function () { + const body = await activityPubContextify(getAnnounceWithoutContext(servers[1]), 'Announce', fakeFilter()) + const headers = buildGlobalHTTPHeaders(body) + + const { statusCode } = await makePOSTAPRequest(url, body, baseHttpSignature(), headers) + expect(statusCode).to.equal(HttpStatusCode.NO_CONTENT_204) + }) + + it('Should refresh the actor keys', async function () { + this.timeout(20000) + + // Update keys of server 2 to invalid keys + // Server 1 should refresh the actor and fail + await setKeysOfServer(sqlCommands[1], servers[1].url, invalidKeys.publicKey, invalidKeys.privateKey) + await setUpdatedAtOfServer(sqlCommands[0], servers[1].url, '2015-07-17 22:00:00+00') + + // Invalid peertube actor cache + await killallServers([ servers[1] ]) + await servers[1].run() + + const body = await activityPubContextify(getAnnounceWithoutContext(servers[1]), 'Announce', fakeFilter()) + const headers = buildGlobalHTTPHeaders(body) + + try { + await makePOSTAPRequest(url, body, baseHttpSignature(), headers) + expect(true, 'Did not throw').to.be.false + } catch (err) { + console.error(err) + expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) + } + }) + }) + + describe('When checking Linked Data Signature', function () { + before(async function () { + await setKeysOfServer(sqlCommands[0], servers[1].url, keys.publicKey, keys.privateKey) + await setKeysOfServer(sqlCommands[1], servers[1].url, keys.publicKey, keys.privateKey) + await setKeysOfServer(sqlCommands[2], servers[2].url, keys.publicKey, keys.privateKey) + + const to = { url: servers[0].url + '/accounts/peertube' } + const by = { url: servers[2].url + '/accounts/peertube', privateKey: keys.privateKey } + await makeFollowRequest(to, by) + }) + + it('Should fail with bad keys', async function () { + await setKeysOfServer(sqlCommands[0], servers[2].url, invalidKeys.publicKey, invalidKeys.privateKey) + await setKeysOfServer(sqlCommands[2], servers[2].url, invalidKeys.publicKey, invalidKeys.privateKey) + + const body = getAnnounceWithoutContext(servers[1]) + body.actor = servers[2].url + '/accounts/peertube' + + const signer: any = { privateKey: invalidKeys.privateKey, url: servers[2].url + '/accounts/peertube' } + const signedBody = await signAndContextify(signer, body, 'Announce', fakeFilter()) + + const headers = buildGlobalHTTPHeaders(signedBody) + + try { + await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers) + expect(true, 'Did not throw').to.be.false + } catch (err) { + expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) + } + }) + + it('Should fail with an altered body', async function () { + await setKeysOfServer(sqlCommands[0], servers[2].url, keys.publicKey, keys.privateKey) + await setKeysOfServer(sqlCommands[0], servers[2].url, keys.publicKey, keys.privateKey) + + const body = getAnnounceWithoutContext(servers[1]) + body.actor = servers[2].url + '/accounts/peertube' + + const signer: any = { privateKey: keys.privateKey, url: servers[2].url + '/accounts/peertube' } + const signedBody = await signAndContextify(signer, body, 'Announce', fakeFilter()) + + signedBody.actor = servers[2].url + '/account/peertube' + + const headers = buildGlobalHTTPHeaders(signedBody) + + try { + await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers) + expect(true, 'Did not throw').to.be.false + } catch (err) { + expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) + } + }) + + it('Should succeed with a valid signature', async function () { + const body = getAnnounceWithoutContext(servers[1]) + body.actor = servers[2].url + '/accounts/peertube' + + const signer: any = { privateKey: keys.privateKey, url: servers[2].url + '/accounts/peertube' } + const signedBody = await signAndContextify(signer, body, 'Announce', fakeFilter()) + + const headers = buildGlobalHTTPHeaders(signedBody) + + const { statusCode } = await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers) + expect(statusCode).to.equal(HttpStatusCode.NO_CONTENT_204) + }) + + it('Should refresh the actor keys', async function () { + this.timeout(20000) + + // Wait refresh invalidation + await wait(10000) + + // Update keys of server 3 to invalid keys + // Server 1 should refresh the actor and fail + await setKeysOfServer(sqlCommands[2], servers[2].url, invalidKeys.publicKey, invalidKeys.privateKey) + + const body = getAnnounceWithoutContext(servers[1]) + body.actor = servers[2].url + '/accounts/peertube' + + const signer: any = { privateKey: keys.privateKey, url: servers[2].url + '/accounts/peertube' } + const signedBody = await signAndContextify(signer, body, 'Announce', fakeFilter()) + + const headers = buildGlobalHTTPHeaders(signedBody) + + try { + await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers) + expect(true, 'Did not throw').to.be.false + } catch (err) { + expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) + } + }) + }) + + after(async function () { + for (const sql of sqlCommands) { + await sql.cleanup() + } + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/check-params/abuses.ts b/packages/tests/src/api/check-params/abuses.ts new file mode 100644 index 000000000..1effc82b1 --- /dev/null +++ b/packages/tests/src/api/check-params/abuses.ts @@ -0,0 +1,438 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { AbuseCreate, AbuseState, HttpStatusCode } from '@peertube/peertube-models' +import { + AbusesCommand, + cleanupTests, + createSingleServer, + doubleFollow, + makeGetRequest, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test abuses API validators', function () { + const basePath = '/api/v1/abuses/' + + let server: PeerTubeServer + + let userToken = '' + let userToken2 = '' + let abuseId: number + let messageId: number + + let command: AbusesCommand + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + userToken = await server.users.generateUserAndToken('user_1') + userToken2 = await server.users.generateUserAndToken('user_2') + + server.store.videoCreated = await server.videos.upload() + + command = server.abuses + }) + + describe('When listing abuses for admins', function () { + const path = basePath + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a bad id filter', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { id: 'toto' } }) + }) + + it('Should fail with a bad filter', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { filter: 'toto' } }) + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { filter: 'videos' } }) + }) + + it('Should fail with bad predefined reason', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { predefinedReason: 'violentOrRepulsives' } }) + }) + + it('Should fail with a bad state filter', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { state: 'toto' } }) + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { state: 0 } }) + }) + + it('Should fail with a bad videoIs filter', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { videoIs: 'toto' } }) + }) + + it('Should succeed with the correct params', async function () { + const query = { + id: 13, + predefinedReason: 'violentOrRepulsive', + filter: 'comment', + state: 2, + videoIs: 'deleted' + } + + await makeGetRequest({ url: server.url, path, token: server.accessToken, query, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When listing abuses for users', function () { + const path = '/api/v1/users/me/abuses' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, userToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, userToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, userToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad id filter', async function () { + await makeGetRequest({ url: server.url, path, token: userToken, query: { id: 'toto' } }) + }) + + it('Should fail with a bad state filter', async function () { + await makeGetRequest({ url: server.url, path, token: userToken, query: { state: 'toto' } }) + await makeGetRequest({ url: server.url, path, token: userToken, query: { state: 0 } }) + }) + + it('Should succeed with the correct params', async function () { + const query = { + id: 13, + state: 2 + } + + await makeGetRequest({ url: server.url, path, token: userToken, query, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When reporting an abuse', function () { + const path = basePath + + it('Should fail with nothing', async function () { + const fields = {} + await makePostBodyRequest({ url: server.url, path, token: userToken, fields }) + }) + + it('Should fail with a wrong video', async function () { + const fields = { video: { id: 'blabla' }, reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path, token: userToken, fields }) + }) + + it('Should fail with an unknown video', async function () { + const fields = { video: { id: 42 }, reason: 'my super reason' } + await makePostBodyRequest({ + url: server.url, + path, + token: userToken, + fields, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a wrong comment', async function () { + const fields = { comment: { id: 'blabla' }, reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path, token: userToken, fields }) + }) + + it('Should fail with an unknown comment', async function () { + const fields = { comment: { id: 42 }, reason: 'my super reason' } + await makePostBodyRequest({ + url: server.url, + path, + token: userToken, + fields, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a wrong account', async function () { + const fields = { account: { id: 'blabla' }, reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path, token: userToken, fields }) + }) + + it('Should fail with an unknown account', async function () { + const fields = { account: { id: 42 }, reason: 'my super reason' } + await makePostBodyRequest({ + url: server.url, + path, + token: userToken, + fields, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with not account, comment or video', async function () { + const fields = { reason: 'my super reason' } + await makePostBodyRequest({ + url: server.url, + path, + token: userToken, + fields, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a non authenticated user', async function () { + const fields = { video: { id: server.store.videoCreated.id }, reason: 'my super reason' } + + await makePostBodyRequest({ url: server.url, path, token: 'hello', fields, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a reason too short', async function () { + const fields = { video: { id: server.store.videoCreated.id }, reason: 'h' } + + await makePostBodyRequest({ url: server.url, path, token: userToken, fields }) + }) + + it('Should fail with a too big reason', async function () { + const fields = { video: { id: server.store.videoCreated.id }, reason: 'super'.repeat(605) } + + await makePostBodyRequest({ url: server.url, path, token: userToken, fields }) + }) + + it('Should succeed with the correct parameters (basic)', async function () { + const fields: AbuseCreate = { video: { id: server.store.videoCreated.shortUUID }, reason: 'my super reason' } + + const res = await makePostBodyRequest({ + url: server.url, + path, + token: userToken, + fields, + expectedStatus: HttpStatusCode.OK_200 + }) + abuseId = res.body.abuse.id + }) + + it('Should fail with a wrong predefined reason', async function () { + const fields = { video: server.store.videoCreated, reason: 'my super reason', predefinedReasons: [ 'wrongPredefinedReason' ] } + + await makePostBodyRequest({ url: server.url, path, token: userToken, fields }) + }) + + it('Should fail with negative timestamps', async function () { + const fields = { video: { id: server.store.videoCreated.id, startAt: -1 }, reason: 'my super reason' } + + await makePostBodyRequest({ url: server.url, path, token: userToken, fields }) + }) + + it('Should fail mith misordered startAt/endAt', async function () { + const fields = { video: { id: server.store.videoCreated.id, startAt: 5, endAt: 1 }, reason: 'my super reason' } + + await makePostBodyRequest({ url: server.url, path, token: userToken, fields }) + }) + + it('Should succeed with the correct parameters (advanced)', async function () { + const fields: AbuseCreate = { + video: { + id: server.store.videoCreated.id, + startAt: 1, + endAt: 5 + }, + reason: 'my super reason', + predefinedReasons: [ 'serverRules' ] + } + + await makePostBodyRequest({ url: server.url, path, token: userToken, fields, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When updating an abuse', function () { + + it('Should fail with a non authenticated user', async function () { + await command.update({ token: 'blabla', abuseId, body: {}, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a non admin user', async function () { + await command.update({ token: userToken, abuseId, body: {}, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad abuse id', async function () { + await command.update({ abuseId: 45, body: {}, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a bad state', async function () { + const body = { state: 5 as any } + await command.update({ abuseId, body, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a bad moderation comment', async function () { + const body = { moderationComment: 'b'.repeat(3001) } + await command.update({ abuseId, body, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with the correct params', async function () { + const body = { state: AbuseState.ACCEPTED } + await command.update({ abuseId, body }) + }) + }) + + describe('When creating an abuse message', function () { + const message = 'my super message' + + it('Should fail with an invalid abuse id', async function () { + await command.addMessage({ token: userToken2, abuseId: 888, message, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a non authenticated user', async function () { + await command.addMessage({ token: 'fake_token', abuseId, message, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with an invalid logged in user', async function () { + await command.addMessage({ token: userToken2, abuseId, message, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with an invalid message', async function () { + await command.addMessage({ token: userToken, abuseId, message: 'a'.repeat(5000), expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with the correct params', async function () { + const res = await command.addMessage({ token: userToken, abuseId, message }) + messageId = res.body.abuseMessage.id + }) + }) + + describe('When listing abuse messages', function () { + + it('Should fail with an invalid abuse id', async function () { + await command.listMessages({ token: userToken, abuseId: 888, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a non authenticated user', async function () { + await command.listMessages({ token: 'fake_token', abuseId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with an invalid logged in user', async function () { + await command.listMessages({ token: userToken2, abuseId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct params', async function () { + await command.listMessages({ token: userToken, abuseId }) + }) + }) + + describe('When deleting an abuse message', function () { + it('Should fail with an invalid abuse id', async function () { + await command.deleteMessage({ token: userToken, abuseId: 888, messageId, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with an invalid message id', async function () { + await command.deleteMessage({ token: userToken, abuseId, messageId: 888, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a non authenticated user', async function () { + await command.deleteMessage({ token: 'fake_token', abuseId, messageId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with an invalid logged in user', async function () { + await command.deleteMessage({ token: userToken2, abuseId, messageId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct params', async function () { + await command.deleteMessage({ token: userToken, abuseId, messageId }) + }) + }) + + describe('When deleting a video abuse', function () { + + it('Should fail with a non authenticated user', async function () { + await command.delete({ token: 'blabla', abuseId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a non admin user', async function () { + await command.delete({ token: userToken, abuseId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad abuse id', async function () { + await command.delete({ abuseId: 45, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await command.delete({ abuseId }) + }) + }) + + describe('When trying to manage messages of a remote abuse', function () { + let remoteAbuseId: number + let anotherServer: PeerTubeServer + + before(async function () { + this.timeout(50000) + + anotherServer = await createSingleServer(2) + await setAccessTokensToServers([ anotherServer ]) + + await doubleFollow(anotherServer, server) + + const server2VideoId = await anotherServer.videos.getId({ uuid: server.store.videoCreated.uuid }) + await anotherServer.abuses.report({ reason: 'remote server', videoId: server2VideoId }) + + await waitJobs([ server, anotherServer ]) + + const body = await command.getAdminList({ sort: '-createdAt' }) + remoteAbuseId = body.data[0].id + }) + + it('Should fail when listing abuse messages of a remote abuse', async function () { + await command.listMessages({ abuseId: remoteAbuseId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail when creating abuse message of a remote abuse', async function () { + await command.addMessage({ abuseId: remoteAbuseId, message: 'message', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + after(async function () { + await cleanupTests([ anotherServer ]) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/accounts.ts b/packages/tests/src/api/check-params/accounts.ts new file mode 100644 index 000000000..87810bbd3 --- /dev/null +++ b/packages/tests/src/api/check-params/accounts.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { HttpStatusCode } from '@peertube/peertube-models' +import { cleanupTests, createSingleServer, PeerTubeServer } from '@peertube/peertube-server-commands' + +describe('Test accounts API validators', function () { + const path = '/api/v1/accounts/' + let server: PeerTubeServer + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + }) + + describe('When listing accounts', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + }) + + describe('When getting an account', function () { + + it('Should return 404 with a non existing name', async function () { + await server.accounts.get({ accountName: 'arfaze', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/blocklist.ts b/packages/tests/src/api/check-params/blocklist.ts new file mode 100644 index 000000000..fcd6d08f8 --- /dev/null +++ b/packages/tests/src/api/check-params/blocklist.ts @@ -0,0 +1,556 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test blocklist API validators', function () { + let servers: PeerTubeServer[] + let server: PeerTubeServer + let userAccessToken: string + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + server = servers[0] + + const user = { username: 'user1', password: 'password' } + await server.users.create({ username: user.username, password: user.password }) + + userAccessToken = await server.login.getAccessToken(user) + + await doubleFollow(servers[0], servers[1]) + }) + + // --------------------------------------------------------------- + + describe('When managing user blocklist', function () { + + describe('When managing user accounts blocklist', function () { + const path = '/api/v1/users/me/blocklist/accounts' + + describe('When listing blocked accounts', function () { + it('Should fail with an unauthenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + }) + + describe('When blocking an account', function () { + it('Should fail with an unauthenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { accountName: 'user1' }, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with an unknown account', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { accountName: 'user2' }, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail to block ourselves', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { accountName: 'root' }, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { accountName: 'user1' }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When unblocking an account', function () { + it('Should fail with an unauthenticated user', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/user1', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with an unknown account block', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/user2', + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct params', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/user1', + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + }) + + describe('When managing user servers blocklist', function () { + const path = '/api/v1/users/me/blocklist/servers' + + describe('When listing blocked servers', function () { + it('Should fail with an unauthenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + }) + + describe('When blocking a server', function () { + it('Should fail with an unauthenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { host: '127.0.0.1:9002' }, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should succeed with an unknown server', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { host: '127.0.0.1:9003' }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + + it('Should fail with our own server', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { host: server.host }, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { host: servers[1].host }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When unblocking a server', function () { + it('Should fail with an unauthenticated user', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/' + servers[1].host, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with an unknown server block', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/127.0.0.1:9004', + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct params', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/' + servers[1].host, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + }) + }) + + describe('When managing server blocklist', function () { + + describe('When managing server accounts blocklist', function () { + const path = '/api/v1/server/blocklist/accounts' + + describe('When listing blocked accounts', function () { + it('Should fail with an unauthenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a user without the appropriate rights', async function () { + await makeGetRequest({ + url: server.url, + token: userAccessToken, + path, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + }) + + describe('When blocking an account', function () { + it('Should fail with an unauthenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { accountName: 'user1' }, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a user without the appropriate rights', async function () { + await makePostBodyRequest({ + url: server.url, + token: userAccessToken, + path, + fields: { accountName: 'user1' }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an unknown account', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { accountName: 'user2' }, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail to block ourselves', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { accountName: 'root' }, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { accountName: 'user1' }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When unblocking an account', function () { + it('Should fail with an unauthenticated user', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/user1', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a user without the appropriate rights', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/user1', + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an unknown account block', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/user2', + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct params', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/user1', + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + }) + + describe('When managing server servers blocklist', function () { + const path = '/api/v1/server/blocklist/servers' + + describe('When listing blocked servers', function () { + it('Should fail with an unauthenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a user without the appropriate rights', async function () { + await makeGetRequest({ + url: server.url, + token: userAccessToken, + path, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + }) + + describe('When blocking a server', function () { + it('Should fail with an unauthenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { host: servers[1].host }, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a user without the appropriate rights', async function () { + await makePostBodyRequest({ + url: server.url, + token: userAccessToken, + path, + fields: { host: servers[1].host }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with an unknown server', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { host: '127.0.0.1:9003' }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + + it('Should fail with our own server', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { host: server.host }, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { host: servers[1].host }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When unblocking a server', function () { + it('Should fail with an unauthenticated user', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/' + servers[1].host, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a user without the appropriate rights', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/' + servers[1].host, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an unknown server block', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/127.0.0.1:9004', + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct params', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/' + servers[1].host, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + }) + }) + + describe('When getting blocklist status', function () { + const path = '/api/v1/blocklist/status' + + it('Should fail with a bad token', async function () { + await makeGetRequest({ + url: server.url, + path, + token: 'false', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad accounts field', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + accounts: 1 + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeGetRequest({ + url: server.url, + path, + query: { + accounts: [ 1 ] + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad hosts field', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + hosts: 1 + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeGetRequest({ + url: server.url, + path, + query: { + hosts: [ 1 ] + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path, + query: {}, + expectedStatus: HttpStatusCode.OK_200 + }) + + await makeGetRequest({ + url: server.url, + path, + query: { + hosts: [ 'example.com' ], + accounts: [ 'john@example.com' ] + }, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/check-params/bulk.ts b/packages/tests/src/api/check-params/bulk.ts new file mode 100644 index 000000000..def0c38eb --- /dev/null +++ b/packages/tests/src/api/check-params/bulk.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test bulk API validators', function () { + let server: PeerTubeServer + let userAccessToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + const user = { username: 'user1', password: 'password' } + await server.users.create({ username: user.username, password: user.password }) + + userAccessToken = await server.login.getAccessToken(user) + }) + + describe('When removing comments of', function () { + const path = '/api/v1/bulk/remove-comments-of' + + it('Should fail with an unauthenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { accountName: 'user1', scope: 'my-videos' }, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with an unknown account', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { accountName: 'user2', scope: 'my-videos' }, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with an invalid scope', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { accountName: 'user1', scope: 'my-videoss' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to delete comments of the instance without the appropriate rights', async function () { + await makePostBodyRequest({ + url: server.url, + token: userAccessToken, + path, + fields: { accountName: 'user1', scope: 'instance' }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path, + fields: { accountName: 'user1', scope: 'instance' }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/channel-import-videos.ts b/packages/tests/src/api/check-params/channel-import-videos.ts new file mode 100644 index 000000000..0e897dad7 --- /dev/null +++ b/packages/tests/src/api/check-params/channel-import-videos.ts @@ -0,0 +1,209 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + ChannelsCommand, + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test videos import in a channel API validator', function () { + let server: PeerTubeServer + const userInfo = { + accessToken: '', + channelName: 'fake_channel', + channelId: -1, + id: -1, + videoQuota: -1, + videoQuotaDaily: -1, + channelSyncId: -1 + } + let command: ChannelsCommand + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableImports() + await server.config.enableChannelSync() + + const userCreds = { + username: 'fake', + password: 'fake_password' + } + + { + const user = await server.users.create({ username: userCreds.username, password: userCreds.password }) + userInfo.id = user.id + userInfo.accessToken = await server.login.getAccessToken(userCreds) + + const info = await server.users.getMyInfo({ token: userInfo.accessToken }) + userInfo.channelId = info.videoChannels[0].id + } + + { + const { videoChannelSync } = await server.channelSyncs.create({ + token: userInfo.accessToken, + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelId: userInfo.channelId + } + }) + userInfo.channelSyncId = videoChannelSync.id + } + + command = server.channels + }) + + it('Should fail when HTTP upload is disabled', async function () { + await server.config.disableChannelSync() + await server.config.disableImports() + + await command.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + token: server.accessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + + await server.config.enableImports() + }) + + it('Should fail when externalChannelUrl is not provided', async function () { + await command.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: null, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail when externalChannelUrl is malformed', async function () { + await command.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: 'not-a-url', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad sync id', async function () { + await command.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelSyncId: 'toto' as any, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a unknown sync id', async function () { + await command.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelSyncId: 42, + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a sync id of another channel', async function () { + await command.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelSyncId: userInfo.channelSyncId, + token: server.accessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with no authentication', async function () { + await command.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + token: null, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail when sync is not owned by the user', async function () { + await command.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + token: userInfo.accessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail when the user has no quota', async function () { + await server.users.update({ + userId: userInfo.id, + videoQuota: 0 + }) + + await command.importVideos({ + channelName: 'fake_channel', + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + token: userInfo.accessToken, + expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413 + }) + + await server.users.update({ + userId: userInfo.id, + videoQuota: userInfo.videoQuota + }) + }) + + it('Should fail when the user has no daily quota', async function () { + await server.users.update({ + userId: userInfo.id, + videoQuotaDaily: 0 + }) + + await command.importVideos({ + channelName: 'fake_channel', + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + token: userInfo.accessToken, + expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413 + }) + + await server.users.update({ + userId: userInfo.id, + videoQuotaDaily: userInfo.videoQuotaDaily + }) + }) + + it('Should succeed when sync is run by its owner', async function () { + if (!areHttpImportTestsDisabled()) return + + await command.importVideos({ + channelName: 'fake_channel', + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + token: userInfo.accessToken + }) + }) + + it('Should succeed when sync is run with root and for another user\'s channel', async function () { + if (!areHttpImportTestsDisabled()) return + + await command.importVideos({ + channelName: 'fake_channel', + externalChannelUrl: FIXTURE_URLS.youtubeChannel + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/config.ts b/packages/tests/src/api/check-params/config.ts new file mode 100644 index 000000000..8179a8815 --- /dev/null +++ b/packages/tests/src/api/check-params/config.ts @@ -0,0 +1,428 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import merge from 'lodash-es/merge.js' +import { omit } from '@peertube/peertube-core-utils' +import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeDeleteRequest, + makeGetRequest, + makePutBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test config API validators', function () { + const path = '/api/v1/config/custom' + let server: PeerTubeServer + let userAccessToken: string + const updateParams: CustomConfig = { + instance: { + name: 'PeerTube updated', + shortDescription: 'my short description', + description: 'my super description', + terms: 'my super terms', + codeOfConduct: 'my super coc', + + creationReason: 'my super reason', + moderationInformation: 'my super moderation information', + administrator: 'Kuja', + maintenanceLifetime: 'forever', + businessModel: 'my super business model', + hardwareInformation: '2vCore 3GB RAM', + + languages: [ 'en', 'es' ], + categories: [ 1, 2 ], + + isNSFW: true, + defaultNSFWPolicy: 'blur', + + defaultClientRoute: '/videos/recently-added', + + customizations: { + javascript: 'alert("coucou")', + css: 'body { background-color: red; }' + } + }, + theme: { + default: 'default' + }, + services: { + twitter: { + username: '@MySuperUsername', + whitelisted: true + } + }, + client: { + videos: { + miniature: { + preferAuthorDisplayName: false + } + }, + menu: { + login: { + redirectOnSingleExternalAuth: false + } + } + }, + cache: { + previews: { + size: 2 + }, + captions: { + size: 3 + }, + torrents: { + size: 4 + }, + storyboards: { + size: 5 + } + }, + signup: { + enabled: false, + limit: 5, + requiresApproval: false, + requiresEmailVerification: false, + minimumAge: 16 + }, + admin: { + email: 'superadmin1@example.com' + }, + contactForm: { + enabled: false + }, + user: { + history: { + videos: { + enabled: true + } + }, + videoQuota: 5242881, + videoQuotaDaily: 318742 + }, + videoChannels: { + maxPerUser: 20 + }, + transcoding: { + enabled: true, + remoteRunners: { + enabled: true + }, + allowAdditionalExtensions: true, + allowAudioFiles: true, + concurrency: 1, + threads: 1, + profile: 'vod_profile', + resolutions: { + '0p': false, + '144p': false, + '240p': false, + '360p': true, + '480p': true, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + alwaysTranscodeOriginalResolution: false, + webVideos: { + enabled: true + }, + hls: { + enabled: false + } + }, + live: { + enabled: true, + + allowReplay: false, + latencySetting: { + enabled: false + }, + maxDuration: 30, + maxInstanceLives: -1, + maxUserLives: 50, + + transcoding: { + enabled: true, + remoteRunners: { + enabled: true + }, + threads: 4, + profile: 'live_profile', + resolutions: { + '144p': true, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + }, + alwaysTranscodeOriginalResolution: false + } + }, + videoStudio: { + enabled: true, + remoteRunners: { + enabled: true + } + }, + videoFile: { + update: { + enabled: true + } + }, + import: { + videos: { + concurrency: 1, + http: { + enabled: false + }, + torrent: { + enabled: false + } + }, + videoChannelSynchronization: { + enabled: false, + maxPerUser: 10 + } + }, + trending: { + videos: { + algorithms: { + enabled: [ 'hot', 'most-viewed', 'most-liked' ], + default: 'most-viewed' + } + } + }, + autoBlacklist: { + videos: { + ofUsers: { + enabled: false + } + } + }, + followers: { + instance: { + enabled: false, + manualApproval: true + } + }, + followings: { + instance: { + autoFollowBack: { + enabled: true + }, + autoFollowIndex: { + enabled: true, + indexUrl: 'https://index.example.com' + } + } + }, + broadcastMessage: { + enabled: true, + dismissable: true, + message: 'super message', + level: 'warning' + }, + search: { + remoteUri: { + users: true, + anonymous: true + }, + searchIndex: { + enabled: true, + url: 'https://search.joinpeertube.org', + disableLocalSearch: true, + isDefaultSearch: true + } + } + } + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const user = { + username: 'user1', + password: 'password' + } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + }) + + describe('When getting the configuration', function () { + it('Should fail without token', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + describe('When updating the configuration', function () { + it('Should fail without token', async function () { + await makePutBodyRequest({ + url: server.url, + path, + fields: updateParams, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makePutBodyRequest({ + url: server.url, + path, + fields: updateParams, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail if it misses a key', async function () { + const newUpdateParams = { ...updateParams, admin: omit(updateParams.admin, [ 'email' ]) } + + await makePutBodyRequest({ + url: server.url, + path, + fields: newUpdateParams, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad default NSFW policy', async function () { + const newUpdateParams = { + ...updateParams, + + instance: { + defaultNSFWPolicy: 'hello' + } + } + + await makePutBodyRequest({ + url: server.url, + path, + fields: newUpdateParams, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail if email disabled and signup requires email verification', async function () { + // opposite scenario - success when enable enabled - covered via tests/api/users/user-verification.ts + const newUpdateParams = { + ...updateParams, + + signup: { + enabled: true, + limit: 5, + requiresApproval: true, + requiresEmailVerification: true + } + } + + await makePutBodyRequest({ + url: server.url, + path, + fields: newUpdateParams, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a disabled web videos & hls transcoding', async function () { + const newUpdateParams = { + ...updateParams, + + transcoding: { + hls: { + enabled: false + }, + web_videos: { + enabled: false + } + } + } + + await makePutBodyRequest({ + url: server.url, + path, + fields: newUpdateParams, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a disabled http upload & enabled sync', async function () { + const newUpdateParams: CustomConfig = merge({}, updateParams, { + import: { + videos: { + http: { enabled: false } + }, + videoChannelSynchronization: { enabled: true } + } + }) + + await makePutBodyRequest({ + url: server.url, + path, + fields: newUpdateParams, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makePutBodyRequest({ + url: server.url, + path, + fields: updateParams, + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When deleting the configuration', function () { + it('Should fail without token', async function () { + await makeDeleteRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makeDeleteRequest({ + url: server.url, + path, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/contact-form.ts b/packages/tests/src/api/check-params/contact-form.ts new file mode 100644 index 000000000..009cb2ad9 --- /dev/null +++ b/packages/tests/src/api/check-params/contact-form.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + ConfigCommand, + ContactFormCommand, + createSingleServer, + killallServers, + PeerTubeServer +} from '@peertube/peertube-server-commands' + +describe('Test contact form API validators', function () { + let server: PeerTubeServer + const emails: object[] = [] + const defaultBody = { + fromName: 'super name', + fromEmail: 'toto@example.com', + subject: 'my subject', + body: 'Hello, how are you?' + } + let emailPort: number + let command: ContactFormCommand + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(60000) + + emailPort = await MockSmtpServer.Instance.collectEmails(emails) + + // Email is disabled + server = await createSingleServer(1) + command = server.contactForm + }) + + it('Should not accept a contact form if emails are disabled', async function () { + await command.send({ ...defaultBody, expectedStatus: HttpStatusCode.CONFLICT_409 }) + }) + + it('Should not accept a contact form if it is disabled in the configuration', async function () { + this.timeout(25000) + + await killallServers([ server ]) + + // Contact form is disabled + await server.run({ ...ConfigCommand.getEmailOverrideConfig(emailPort), contact_form: { enabled: false } }) + await command.send({ ...defaultBody, expectedStatus: HttpStatusCode.CONFLICT_409 }) + }) + + it('Should not accept a contact form if from email is invalid', async function () { + this.timeout(25000) + + await killallServers([ server ]) + + // Email & contact form enabled + await server.run(ConfigCommand.getEmailOverrideConfig(emailPort)) + + await command.send({ ...defaultBody, fromEmail: 'badEmail', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.send({ ...defaultBody, fromEmail: 'badEmail@', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.send({ ...defaultBody, fromEmail: undefined, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not accept a contact form if from name is invalid', async function () { + await command.send({ ...defaultBody, fromName: 'name'.repeat(100), expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.send({ ...defaultBody, fromName: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.send({ ...defaultBody, fromName: undefined, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not accept a contact form if body is invalid', async function () { + await command.send({ ...defaultBody, body: 'body'.repeat(5000), expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.send({ ...defaultBody, body: 'a', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.send({ ...defaultBody, body: undefined, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should accept a contact form with the correct parameters', async function () { + await command.send(defaultBody) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/custom-pages.ts b/packages/tests/src/api/check-params/custom-pages.ts new file mode 100644 index 000000000..180a5e406 --- /dev/null +++ b/packages/tests/src/api/check-params/custom-pages.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + makePutBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test custom pages validators', function () { + const path = '/api/v1/custom-pages/homepage/instance' + + let server: PeerTubeServer + let userAccessToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + const user = { username: 'user1', password: 'password' } + await server.users.create({ username: user.username, password: user.password }) + + userAccessToken = await server.login.getAccessToken(user) + }) + + describe('When updating instance homepage', function () { + + it('Should fail with an unauthenticated user', async function () { + await makePutBodyRequest({ + url: server.url, + path, + fields: { content: 'super content' }, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: userAccessToken, + fields: { content: 'super content' }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { content: 'super content' }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When getting instance homapage', function () { + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/debug.ts b/packages/tests/src/api/check-params/debug.ts new file mode 100644 index 000000000..4a7c18a62 --- /dev/null +++ b/packages/tests/src/api/check-params/debug.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test debug API validators', function () { + const path = '/api/v1/server/debug' + let server: PeerTubeServer + let userAccessToken = '' + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const user = { + username: 'user1', + password: 'my super password' + } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + }) + + describe('When getting debug endpoint', function () { + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query: { startDate: new Date().toISOString() }, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/follows.ts b/packages/tests/src/api/check-params/follows.ts new file mode 100644 index 000000000..e92a3acd6 --- /dev/null +++ b/packages/tests/src/api/check-params/follows.ts @@ -0,0 +1,369 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test server follows API validators', function () { + let server: PeerTubeServer + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + }) + + describe('When managing following', function () { + let userAccessToken = null + + before(async function () { + userAccessToken = await server.users.generateUserAndToken('user1') + }) + + describe('When adding follows', function () { + const path = '/api/v1/server/following' + + it('Should fail with nothing', async function () { + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail if hosts is not composed by hosts', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { hosts: [ '127.0.0.1:9002', '127.0.0.1:coucou' ] }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail if hosts is composed with http schemes', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { hosts: [ '127.0.0.1:9002', 'http://127.0.0.1:9003' ] }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail if hosts are not unique', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { urls: [ '127.0.0.1:9002', '127.0.0.1:9002' ] }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail if handles is not composed by handles', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { handles: [ 'hello@example.com', '127.0.0.1:9001' ] }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail if handles are not unique', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { urls: [ 'hello@example.com', 'hello@example.com' ] }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an invalid token', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { hosts: [ '127.0.0.1:9002' ] }, + token: 'fake_token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { hosts: [ '127.0.0.1:9002' ] }, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + describe('When listing followings', function () { + const path = '/api/v1/server/following' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path) + }) + + it('Should fail with an incorrect state', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + state: 'blabla' + } + }) + }) + + it('Should fail with an incorrect actor type', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + actorType: 'blabla' + } + }) + }) + + it('Should fail succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.OK_200, + query: { + state: 'accepted', + actorType: 'Application' + } + }) + }) + }) + + describe('When listing followers', function () { + const path = '/api/v1/server/followers' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path) + }) + + it('Should fail with an incorrect actor type', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + actorType: 'blabla' + } + }) + }) + + it('Should fail with an incorrect state', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + state: 'blabla', + actorType: 'Application' + } + }) + }) + + it('Should fail succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.OK_200, + query: { + state: 'accepted' + } + }) + }) + }) + + describe('When removing a follower', function () { + const path = '/api/v1/server/followers' + + it('Should fail with an invalid token', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/toto@127.0.0.1:9002', + token: 'fake_token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/toto@127.0.0.1:9002', + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid follower', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/toto', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an unknown follower', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/toto@127.0.0.1:9003', + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + }) + + describe('When accepting a follower', function () { + const path = '/api/v1/server/followers' + + it('Should fail with an invalid token', async function () { + await makePostBodyRequest({ + url: server.url, + path: path + '/toto@127.0.0.1:9002/accept', + token: 'fake_token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makePostBodyRequest({ + url: server.url, + path: path + '/toto@127.0.0.1:9002/accept', + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid follower', async function () { + await makePostBodyRequest({ + url: server.url, + path: path + '/toto/accept', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an unknown follower', async function () { + await makePostBodyRequest({ + url: server.url, + path: path + '/toto@127.0.0.1:9003/accept', + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + }) + + describe('When rejecting a follower', function () { + const path = '/api/v1/server/followers' + + it('Should fail with an invalid token', async function () { + await makePostBodyRequest({ + url: server.url, + path: path + '/toto@127.0.0.1:9002/reject', + token: 'fake_token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makePostBodyRequest({ + url: server.url, + path: path + '/toto@127.0.0.1:9002/reject', + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid follower', async function () { + await makePostBodyRequest({ + url: server.url, + path: path + '/toto/reject', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an unknown follower', async function () { + await makePostBodyRequest({ + url: server.url, + path: path + '/toto@127.0.0.1:9003/reject', + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + }) + + describe('When removing following', function () { + const path = '/api/v1/server/following' + + it('Should fail with an invalid token', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/127.0.0.1:9002', + token: 'fake_token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/127.0.0.1:9002', + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail if we do not follow this server', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/example.com', + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts new file mode 100644 index 000000000..ed5fe6b06 --- /dev/null +++ b/packages/tests/src/api/check-params/index.ts @@ -0,0 +1,45 @@ +import './abuses.js' +import './accounts.js' +import './blocklist.js' +import './bulk.js' +import './channel-import-videos.js' +import './config.js' +import './contact-form.js' +import './custom-pages.js' +import './debug.js' +import './follows.js' +import './jobs.js' +import './live.js' +import './logs.js' +import './metrics.js' +import './my-user.js' +import './plugins.js' +import './redundancy.js' +import './registrations.js' +import './runners.js' +import './search.js' +import './services.js' +import './transcoding.js' +import './two-factor.js' +import './upload-quota.js' +import './user-notifications.js' +import './user-subscriptions.js' +import './users-admin.js' +import './users-emails.js' +import './video-blacklist.js' +import './video-captions.js' +import './video-channel-syncs.js' +import './video-channels.js' +import './video-comments.js' +import './video-files.js' +import './video-imports.js' +import './video-playlists.js' +import './video-storyboards.js' +import './video-source.js' +import './video-studio.js' +import './video-token.js' +import './videos-common-filters.js' +import './videos-history.js' +import './videos-overviews.js' +import './videos.js' +import './views.js' diff --git a/packages/tests/src/api/check-params/jobs.ts b/packages/tests/src/api/check-params/jobs.ts new file mode 100644 index 000000000..331d58c6a --- /dev/null +++ b/packages/tests/src/api/check-params/jobs.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test jobs API validators', function () { + const path = '/api/v1/jobs/failed' + let server: PeerTubeServer + let userAccessToken = '' + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const user = { + username: 'user1', + password: 'my super password' + } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + }) + + describe('When listing jobs', function () { + + it('Should fail with a bad state', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path: path + 'ade' + }) + }) + + it('Should fail with an incorrect job type', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path, + query: { + jobType: 'toto' + } + }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + describe('When pausing/resuming the job queue', async function () { + const commands = [ 'pause', 'resume' ] + + it('Should fail with a non authenticated user', async function () { + for (const command of commands) { + await makePostBodyRequest({ + url: server.url, + path: '/api/v1/jobs/' + command, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + } + }) + + it('Should fail with a non admin user', async function () { + for (const command of commands) { + await makePostBodyRequest({ + url: server.url, + path: '/api/v1/jobs/' + command, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + } + }) + + it('Should succeed with the correct params', async function () { + for (const command of commands) { + await makePostBodyRequest({ + url: server.url, + path: '/api/v1/jobs/' + command, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/live.ts b/packages/tests/src/api/check-params/live.ts new file mode 100644 index 000000000..5900823ea --- /dev/null +++ b/packages/tests/src/api/check-params/live.ts @@ -0,0 +1,590 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { omit } from '@peertube/peertube-core-utils' +import { HttpStatusCode, LiveVideoLatencyMode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createSingleServer, + LiveCommand, + makePostBodyRequest, + makeUploadRequest, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + stopFfmpeg +} from '@peertube/peertube-server-commands' + +describe('Test video lives API validator', function () { + const path = '/api/v1/videos/live' + let server: PeerTubeServer + let userAccessToken = '' + let channelId: number + let video: VideoCreateResult + let videoIdNotLive: number + let command: LiveCommand + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + latencySetting: { + enabled: false + }, + maxInstanceLives: 20, + maxUserLives: 20, + allowReplay: true + } + } + }) + + const username = 'user1' + const password = 'my super password' + await server.users.create({ username, password }) + userAccessToken = await server.login.getAccessToken({ username, password }) + + { + const { videoChannels } = await server.users.getMyInfo() + channelId = videoChannels[0].id + } + + { + videoIdNotLive = (await server.videos.quickUpload({ name: 'not live' })).id + } + + command = server.live + }) + + describe('When creating a live', function () { + let baseCorrectParams + + before(function () { + baseCorrectParams = { + name: 'my super name', + category: 5, + licence: 1, + language: 'pt', + nsfw: false, + commentsEnabled: true, + downloadEnabled: true, + waitTranscoding: true, + description: 'my super description', + support: 'my super support text', + tags: [ 'tag1', 'tag2' ], + privacy: VideoPrivacy.PUBLIC, + channelId, + saveReplay: false, + replaySettings: undefined, + permanentLive: false, + latencyMode: LiveVideoLatencyMode.DEFAULT + } + }) + + it('Should fail with nothing', async function () { + const fields = {} + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a long name', async function () { + const fields = { ...baseCorrectParams, name: 'super'.repeat(65) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad category', async function () { + const fields = { ...baseCorrectParams, category: 125 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad licence', async function () { + const fields = { ...baseCorrectParams, licence: 125 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad language', async function () { + const fields = { ...baseCorrectParams, language: 'a'.repeat(15) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a long description', async function () { + const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a long support text', async function () { + const fields = { ...baseCorrectParams, support: 'super'.repeat(201) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail without a channel', async function () { + const fields = omit(baseCorrectParams, [ 'channelId' ]) + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad channel', async function () { + const fields = { ...baseCorrectParams, channelId: 545454 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad privacy for replay settings', async function () { + const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: 999 } } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with another user channel', async function () { + const user = { + username: 'fake', + password: 'fake_password' + } + await server.users.create({ username: user.username, password: user.password }) + + const accessTokenUser = await server.login.getAccessToken(user) + const { videoChannels } = await server.users.getMyInfo({ token: accessTokenUser }) + const customChannelId = videoChannels[0].id + + const fields = { ...baseCorrectParams, channelId: customChannelId } + + await makePostBodyRequest({ url: server.url, path, token: userAccessToken, fields }) + }) + + it('Should fail with too many tags', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a tag length too low', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 't' ] } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a tag length too big', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with an incorrect thumbnail file', async function () { + const fields = baseCorrectParams + const attaches = { + thumbnailfile: buildAbsoluteFixturePath('video_short.mp4') + } + + await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) + }) + + it('Should fail with a big thumbnail file', async function () { + const fields = baseCorrectParams + const attaches = { + thumbnailfile: buildAbsoluteFixturePath('custom-preview-big.png') + } + + await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) + }) + + it('Should fail with an incorrect preview file', async function () { + const fields = baseCorrectParams + const attaches = { + previewfile: buildAbsoluteFixturePath('video_short.mp4') + } + + await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) + }) + + it('Should fail with a big preview file', async function () { + const fields = baseCorrectParams + const attaches = { + previewfile: buildAbsoluteFixturePath('custom-preview-big.png') + } + + await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) + }) + + it('Should fail with bad latency setting', async function () { + const fields = { ...baseCorrectParams, latencyMode: 42 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail to set latency if the server does not allow it', async function () { + const fields = { ...baseCorrectParams, latencyMode: LiveVideoLatencyMode.HIGH_LATENCY } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct parameters', async function () { + this.timeout(30000) + + const res = await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.OK_200 + }) + + video = res.body.video + }) + + it('Should forbid if live is disabled', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: false + } + } + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should forbid to save replay if not enabled by the admin', async function () { + const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: VideoPrivacy.PUBLIC } } + + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: false + } + } + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should allow to save replay if enabled by the admin', async function () { + const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: VideoPrivacy.PUBLIC } } + + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true + } + } + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should not allow live if max instance lives is reached', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + maxInstanceLives: 1 + } + } + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should not allow live if max user lives is reached', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + maxInstanceLives: 20, + maxUserLives: 1 + } + } + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + describe('When getting live information', function () { + + it('Should fail with a bad access token', async function () { + await command.get({ token: 'toto', videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not display private information without access token', async function () { + const live = await command.get({ token: '', videoId: video.id }) + + expect(live.rtmpUrl).to.not.exist + expect(live.streamKey).to.not.exist + expect(live.latencyMode).to.exist + }) + + it('Should not display private information with token of another user', async function () { + const live = await command.get({ token: userAccessToken, videoId: video.id }) + + expect(live.rtmpUrl).to.not.exist + expect(live.streamKey).to.not.exist + expect(live.latencyMode).to.exist + }) + + it('Should display private information with appropriate token', async function () { + const live = await command.get({ videoId: video.id }) + + expect(live.rtmpUrl).to.exist + expect(live.streamKey).to.exist + expect(live.latencyMode).to.exist + }) + + it('Should fail with a bad video id', async function () { + await command.get({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown video id', async function () { + await command.get({ videoId: 454555, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a non live video', async function () { + await command.get({ videoId: videoIdNotLive, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await command.get({ videoId: video.id }) + await command.get({ videoId: video.uuid }) + await command.get({ videoId: video.shortUUID }) + }) + }) + + describe('When getting live sessions', function () { + + it('Should fail with a bad access token', async function () { + await command.listSessions({ token: 'toto', videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail without token', async function () { + await command.listSessions({ token: null, videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with the token of another user', async function () { + await command.listSessions({ token: userAccessToken, videoId: video.id, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad video id', async function () { + await command.listSessions({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown video id', async function () { + await command.listSessions({ videoId: 454555, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a non live video', async function () { + await command.listSessions({ videoId: videoIdNotLive, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await command.listSessions({ videoId: video.id }) + }) + }) + + describe('When getting live session of a replay', function () { + + it('Should fail with a bad video id', async function () { + await command.getReplaySession({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown video id', async function () { + await command.getReplaySession({ videoId: 454555, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a non replay video', async function () { + await command.getReplaySession({ videoId: videoIdNotLive, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('When updating live information', async function () { + + it('Should fail without access token', async function () { + await command.update({ token: '', videoId: video.id, fields: {}, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a bad access token', async function () { + await command.update({ token: 'toto', videoId: video.id, fields: {}, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with access token of another user', async function () { + await command.update({ token: userAccessToken, videoId: video.id, fields: {}, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad video id', async function () { + await command.update({ videoId: 'toto', fields: {}, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown video id', async function () { + await command.update({ videoId: 454555, fields: {}, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a non live video', async function () { + await command.update({ videoId: videoIdNotLive, fields: {}, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with bad latency setting', async function () { + const fields = { latencyMode: 42 as any } + + await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a bad privacy for replay settings', async function () { + const fields = { saveReplay: true, replaySettings: { privacy: 999 as any } } + + await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with save replay enabled but without replay settings', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true + } + } + }) + + const fields = { saveReplay: true } + + await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with save replay disabled and replay settings', async function () { + const fields = { saveReplay: false, replaySettings: { privacy: VideoPrivacy.INTERNAL } } + + await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with only replay settings when save replay is disabled', async function () { + const fields = { replaySettings: { privacy: VideoPrivacy.INTERNAL } } + + await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail to set latency if the server does not allow it', async function () { + const fields = { latencyMode: LiveVideoLatencyMode.HIGH_LATENCY } + + await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct params', async function () { + await command.update({ videoId: video.id, fields: { saveReplay: false } }) + await command.update({ videoId: video.uuid, fields: { saveReplay: false } }) + await command.update({ videoId: video.shortUUID, fields: { saveReplay: false } }) + + await command.update({ videoId: video.id, fields: { saveReplay: true, replaySettings: { privacy: VideoPrivacy.PUBLIC } } }) + + }) + + it('Should fail to update replay status if replay is not allowed on the instance', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: false + } + } + }) + + await command.update({ videoId: video.id, fields: { saveReplay: true }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail to update a live if it has already started', async function () { + this.timeout(40000) + + const live = await command.get({ videoId: video.id }) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + + await command.waitUntilPublished({ videoId: video.id }) + await command.update({ videoId: video.id, fields: {}, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should fail to change live privacy if it has already started', async function () { + this.timeout(40000) + + const live = await command.get({ videoId: video.id }) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + + await command.waitUntilPublished({ videoId: video.id }) + + await server.videos.update({ + id: video.id, + attributes: { privacy: VideoPrivacy.PUBLIC } // Same privacy, it's fine + }) + + await server.videos.update({ + id: video.id, + attributes: { privacy: VideoPrivacy.UNLISTED }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should fail to stream twice in the save live', async function () { + this.timeout(40000) + + const live = await command.get({ videoId: video.id }) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + + await command.waitUntilPublished({ videoId: video.id }) + + await command.runAndTestStreamError({ videoId: video.id, shouldHaveError: true }) + + await stopFfmpeg(ffmpegCommand) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/logs.ts b/packages/tests/src/api/check-params/logs.ts new file mode 100644 index 000000000..629530e30 --- /dev/null +++ b/packages/tests/src/api/check-params/logs.ts @@ -0,0 +1,163 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test logs API validators', function () { + const path = '/api/v1/server/logs' + let server: PeerTubeServer + let userAccessToken = '' + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const user = { + username: 'user1', + password: 'my super password' + } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + }) + + describe('When getting logs', function () { + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a missing startDate query', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad startDate query', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query: { startDate: 'toto' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad endDate query', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query: { startDate: new Date().toISOString(), endDate: 'toto' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad level parameter', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query: { startDate: new Date().toISOString(), level: 'toto' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query: { startDate: new Date().toISOString() }, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When creating client logs', function () { + const base = { + level: 'warn' as 'warn', + message: 'my super message', + url: 'https://example.com/toto' + } + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + + it('Should fail with an invalid level', async function () { + await server.logs.createLogClient({ payload: { ...base, level: '' as any }, expectedStatus }) + await server.logs.createLogClient({ payload: { ...base, level: undefined }, expectedStatus }) + await server.logs.createLogClient({ payload: { ...base, level: 'toto' as any }, expectedStatus }) + }) + + it('Should fail with an invalid message', async function () { + await server.logs.createLogClient({ payload: { ...base, message: undefined }, expectedStatus }) + await server.logs.createLogClient({ payload: { ...base, message: '' }, expectedStatus }) + await server.logs.createLogClient({ payload: { ...base, message: 'm'.repeat(2500) }, expectedStatus }) + }) + + it('Should fail with an invalid url', async function () { + await server.logs.createLogClient({ payload: { ...base, url: undefined }, expectedStatus }) + await server.logs.createLogClient({ payload: { ...base, url: 'toto' }, expectedStatus }) + }) + + it('Should fail with an invalid stackTrace', async function () { + await server.logs.createLogClient({ payload: { ...base, stackTrace: 's'.repeat(20000) }, expectedStatus }) + }) + + it('Should fail with an invalid userAgent', async function () { + await server.logs.createLogClient({ payload: { ...base, userAgent: 's'.repeat(500) }, expectedStatus }) + }) + + it('Should fail with an invalid meta', async function () { + await server.logs.createLogClient({ payload: { ...base, meta: 's'.repeat(10000) }, expectedStatus }) + }) + + it('Should succeed with the correct params', async function () { + await server.logs.createLogClient({ payload: { ...base, stackTrace: 'stackTrace', meta: '{toto}', userAgent: 'userAgent' } }) + }) + + it('Should rate limit log creation', async function () { + let fail = false + + for (let i = 0; i < 10; i++) { + try { + await server.logs.createLogClient({ token: null, payload: base }) + } catch { + fail = true + } + } + + expect(fail).to.be.true + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/metrics.ts b/packages/tests/src/api/check-params/metrics.ts new file mode 100644 index 000000000..cda854554 --- /dev/null +++ b/packages/tests/src/api/check-params/metrics.ts @@ -0,0 +1,214 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { omit } from '@peertube/peertube-core-utils' +import { HttpStatusCode, PlaybackMetricCreate, VideoResolution } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test metrics API validators', function () { + let server: PeerTubeServer + let videoUUID: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1, { + open_telemetry: { + metrics: { + enabled: true + } + } + }) + + await setAccessTokensToServers([ server ]) + + const { uuid } = await server.videos.quickUpload({ name: 'video' }) + videoUUID = uuid + }) + + describe('When adding playback metrics', function () { + const path = '/api/v1/metrics/playback' + let baseParams: PlaybackMetricCreate + + before(function () { + baseParams = { + playerMode: 'p2p-media-loader', + resolution: VideoResolution.H_1080P, + fps: 30, + resolutionChanges: 1, + errors: 2, + p2pEnabled: true, + downloadedBytesP2P: 0, + downloadedBytesHTTP: 0, + uploadedBytesP2P: 0, + videoId: videoUUID + } + }) + + it('Should fail with an invalid resolution', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, resolution: 'toto' } + }) + }) + + it('Should fail with an invalid fps', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, fps: 'toto' } + }) + }) + + it('Should fail with a missing/invalid player mode', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: omit(baseParams, [ 'playerMode' ]) + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, playerMode: 'toto' } + }) + }) + + it('Should fail with an missing/invalid resolution changes', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: omit(baseParams, [ 'resolutionChanges' ]) + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, resolutionChanges: 'toto' } + }) + }) + + it('Should fail with an missing/invalid errors', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: omit(baseParams, [ 'errors' ]) + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, errors: 'toto' } + }) + }) + + it('Should fail with an missing/invalid downloadedBytesP2P', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: omit(baseParams, [ 'downloadedBytesP2P' ]) + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, downloadedBytesP2P: 'toto' } + }) + }) + + it('Should fail with an missing/invalid downloadedBytesHTTP', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: omit(baseParams, [ 'downloadedBytesHTTP' ]) + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, downloadedBytesHTTP: 'toto' } + }) + }) + + it('Should fail with an missing/invalid uploadedBytesP2P', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: omit(baseParams, [ 'uploadedBytesP2P' ]) + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, uploadedBytesP2P: 'toto' } + }) + }) + + it('Should fail with a missing/invalid p2pEnabled', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: omit(baseParams, [ 'p2pEnabled' ]) + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, p2pEnabled: 'toto' } + }) + }) + + it('Should fail with an invalid totalPeers', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, p2pPeers: 'toto' } + }) + }) + + it('Should fail with a bad video id', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, videoId: 'toto' } + }) + }) + + it('Should fail with an unknown video', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, videoId: 42 }, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: baseParams, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { ...baseParams, p2pEnabled: false, totalPeers: 32 }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/my-user.ts b/packages/tests/src/api/check-params/my-user.ts new file mode 100644 index 000000000..2ef2e242a --- /dev/null +++ b/packages/tests/src/api/check-params/my-user.ts @@ -0,0 +1,492 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { HttpStatusCode, UserRole, VideoCreateResult } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + makePutBodyRequest, + makeUploadRequest, + PeerTubeServer, + setAccessTokensToServers, + UsersCommand +} from '@peertube/peertube-server-commands' + +describe('Test my user API validators', function () { + const path = '/api/v1/users/' + let userId: number + let rootId: number + let moderatorId: number + let video: VideoCreateResult + let server: PeerTubeServer + let userToken = '' + let moderatorToken = '' + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + { + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + } + + { + const result = await server.users.generate('user1') + userToken = result.token + userId = result.userId + } + + { + const result = await server.users.generate('moderator1', UserRole.MODERATOR) + moderatorToken = result.token + } + + { + const result = await server.users.generate('moderator2', UserRole.MODERATOR) + moderatorId = result.userId + } + + { + video = await server.videos.upload() + } + }) + + describe('When updating my account', function () { + + it('Should fail with an invalid email attribute', async function () { + const fields = { + email: 'blabla' + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: server.accessToken, fields }) + }) + + it('Should fail with a too small password', async function () { + const fields = { + currentPassword: 'password', + password: 'bla' + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail with a too long password', async function () { + const fields = { + currentPassword: 'password', + password: 'super'.repeat(61) + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail without the current password', async function () { + const fields = { + currentPassword: 'password', + password: 'super'.repeat(61) + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail with an invalid current password', async function () { + const fields = { + currentPassword: 'my super password fail', + password: 'super'.repeat(61) + } + + await makePutBodyRequest({ + url: server.url, + path: path + 'me', + token: userToken, + fields, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with an invalid NSFW policy attribute', async function () { + const fields = { + nsfwPolicy: 'hello' + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail with an invalid autoPlayVideo attribute', async function () { + const fields = { + autoPlayVideo: -1 + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail with an invalid autoPlayNextVideo attribute', async function () { + const fields = { + autoPlayNextVideo: -1 + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail with an invalid videosHistoryEnabled attribute', async function () { + const fields = { + videosHistoryEnabled: -1 + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail with an non authenticated user', async function () { + const fields = { + currentPassword: 'password', + password: 'my super password' + } + + await makePutBodyRequest({ + url: server.url, + path: path + 'me', + token: 'super token', + fields, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a too long description', async function () { + const fields = { + description: 'super'.repeat(201) + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail with an invalid videoLanguages attribute', async function () { + { + const fields = { + videoLanguages: 'toto' + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + } + + { + const languages = [] + for (let i = 0; i < 1000; i++) { + languages.push('fr') + } + + const fields = { + videoLanguages: languages + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + } + }) + + it('Should fail with an invalid theme', async function () { + const fields = { theme: 'invalid' } + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail with an unknown theme', async function () { + const fields = { theme: 'peertube-theme-unknown' } + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + }) + + it('Should fail with invalid no modal attributes', async function () { + const keys = [ + 'noInstanceConfigWarningModal', + 'noAccountSetupWarningModal', + 'noWelcomeModal' + ] + + for (const key of keys) { + const fields = { + [key]: -1 + } + + await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields }) + } + }) + + it('Should succeed to change password with the correct params', async function () { + const fields = { + currentPassword: 'password', + password: 'my super password', + nsfwPolicy: 'blur', + autoPlayVideo: false, + email: 'super_email@example.com', + theme: 'default', + noInstanceConfigWarningModal: true, + noWelcomeModal: true, + noAccountSetupWarningModal: true + } + + await makePutBodyRequest({ + url: server.url, + path: path + 'me', + token: userToken, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + + it('Should succeed without password change with the correct params', async function () { + const fields = { + nsfwPolicy: 'blur', + autoPlayVideo: false + } + + await makePutBodyRequest({ + url: server.url, + path: path + 'me', + token: userToken, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When updating my avatar', function () { + it('Should fail without an incorrect input file', async function () { + const fields = {} + const attaches = { + avatarfile: buildAbsoluteFixturePath('video_short.mp4') + } + await makeUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches }) + }) + + it('Should fail with a big file', async function () { + const fields = {} + const attaches = { + avatarfile: buildAbsoluteFixturePath('avatar-big.png') + } + await makeUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches }) + }) + + it('Should fail with an unauthenticated user', async function () { + const fields = {} + const attaches = { + avatarfile: buildAbsoluteFixturePath('avatar.png') + } + await makeUploadRequest({ + url: server.url, + path: path + '/me/avatar/pick', + fields, + attaches, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should succeed with the correct params', async function () { + const fields = {} + const attaches = { + avatarfile: buildAbsoluteFixturePath('avatar.png') + } + await makeUploadRequest({ + url: server.url, + path: path + '/me/avatar/pick', + token: server.accessToken, + fields, + attaches, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When managing my scoped tokens', function () { + + it('Should fail to get my scoped tokens with an non authenticated user', async function () { + await server.users.getMyScopedTokens({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail to get my scoped tokens with a bad token', async function () { + await server.users.getMyScopedTokens({ token: 'bad', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + + }) + + it('Should succeed to get my scoped tokens', async function () { + await server.users.getMyScopedTokens() + }) + + it('Should fail to renew my scoped tokens with an non authenticated user', async function () { + await server.users.renewMyScopedTokens({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail to renew my scoped tokens with a bad token', async function () { + await server.users.renewMyScopedTokens({ token: 'bad', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should succeed to renew my scoped tokens', async function () { + await server.users.renewMyScopedTokens() + }) + }) + + describe('When getting my information', function () { + it('Should fail with a non authenticated user', async function () { + await server.users.getMyInfo({ token: 'fake_token', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should success with the correct parameters', async function () { + await server.users.getMyInfo({ token: userToken }) + }) + }) + + describe('When getting my video rating', function () { + let command: UsersCommand + + before(function () { + command = server.users + }) + + it('Should fail with a non authenticated user', async function () { + await command.getMyRating({ token: 'fake_token', videoId: video.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with an incorrect video uuid', async function () { + await command.getMyRating({ videoId: 'blabla', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown video', async function () { + await command.getMyRating({ videoId: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct parameters', async function () { + await command.getMyRating({ videoId: video.id }) + await command.getMyRating({ videoId: video.uuid }) + await command.getMyRating({ videoId: video.shortUUID }) + }) + }) + + describe('When retrieving my global ratings', function () { + const path = '/api/v1/accounts/user1/ratings' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, userToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, userToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, userToken) + }) + + it('Should fail with a unauthenticated user', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a another user', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad type', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userToken, + query: { rating: 'toto ' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ url: server.url, path, token: userToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When getting my global followers', function () { + const path = '/api/v1/accounts/user1/followers' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, userToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, userToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, userToken) + }) + + it('Should fail with a unauthenticated user', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a another user', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ url: server.url, path, token: userToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When blocking/unblocking/removing user', function () { + + it('Should fail with an incorrect id', async function () { + const options = { userId: 'blabla' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 } + + await server.users.remove(options) + await server.users.banUser({ userId: 'blabla' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.users.unbanUser({ userId: 'blabla' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with the root user', async function () { + const options = { userId: rootId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 } + + await server.users.remove(options) + await server.users.banUser(options) + await server.users.unbanUser(options) + }) + + it('Should return 404 with a non existing id', async function () { + const options = { userId: 4545454, expectedStatus: HttpStatusCode.NOT_FOUND_404 } + + await server.users.remove(options) + await server.users.banUser(options) + await server.users.unbanUser(options) + }) + + it('Should fail with a non admin user', async function () { + const options = { userId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 } + + await server.users.remove(options) + await server.users.banUser(options) + await server.users.unbanUser(options) + }) + + it('Should fail on a moderator with a moderator', async function () { + const options = { userId: moderatorId, token: moderatorToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 } + + await server.users.remove(options) + await server.users.banUser(options) + await server.users.unbanUser(options) + }) + + it('Should succeed on a user with a moderator', async function () { + const options = { userId, token: moderatorToken } + + await server.users.banUser(options) + await server.users.unbanUser(options) + }) + }) + + describe('When deleting our account', function () { + + it('Should fail with with the root account', async function () { + await server.users.deleteMe({ expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/plugins.ts b/packages/tests/src/api/check-params/plugins.ts new file mode 100644 index 000000000..ab2a426fe --- /dev/null +++ b/packages/tests/src/api/check-params/plugins.ts @@ -0,0 +1,490 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { HttpStatusCode, PeerTubePlugin, PluginType } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + makePostBodyRequest, + makePutBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test server plugins API validators', function () { + let server: PeerTubeServer + let userAccessToken = null + + const npmPlugin = 'peertube-plugin-hello-world' + const pluginName = 'hello-world' + let npmVersion: string + + const themePlugin = 'peertube-theme-background-red' + const themeName = 'background-red' + let themeVersion: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(60000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const user = { + username: 'user1', + password: 'password' + } + + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + + { + const res = await server.plugins.install({ npmName: npmPlugin }) + const plugin = res.body as PeerTubePlugin + npmVersion = plugin.version + } + + { + const res = await server.plugins.install({ npmName: themePlugin }) + const plugin = res.body as PeerTubePlugin + themeVersion = plugin.version + } + }) + + describe('With static plugin routes', function () { + it('Should fail with an unknown plugin name/plugin version', async function () { + const paths = [ + '/plugins/' + pluginName + '/0.0.1/auth/fake-auth', + '/plugins/' + pluginName + '/0.0.1/static/images/chocobo.png', + '/plugins/' + pluginName + '/0.0.1/client-scripts/client/common-client-plugin.js', + '/themes/' + themeName + '/0.0.1/static/images/chocobo.png', + '/themes/' + themeName + '/0.0.1/client-scripts/client/video-watch-client-plugin.js', + '/themes/' + themeName + '/0.0.1/css/assets/style1.css' + ] + + for (const p of paths) { + await makeGetRequest({ url: server.url, path: p, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + }) + + it('Should fail when requesting a plugin in the theme path', async function () { + await makeGetRequest({ + url: server.url, + path: '/themes/' + pluginName + '/' + npmVersion + '/static/images/chocobo.png', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with invalid versions', async function () { + const paths = [ + '/plugins/' + pluginName + '/0.0.1.1/auth/fake-auth', + '/plugins/' + pluginName + '/0.0.1.1/static/images/chocobo.png', + '/plugins/' + pluginName + '/0.1/client-scripts/client/common-client-plugin.js', + '/themes/' + themeName + '/1/static/images/chocobo.png', + '/themes/' + themeName + '/0.0.1000a/client-scripts/client/video-watch-client-plugin.js', + '/themes/' + themeName + '/0.a.1/css/assets/style1.css' + ] + + for (const p of paths) { + await makeGetRequest({ url: server.url, path: p, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + }) + + it('Should fail with invalid paths', async function () { + const paths = [ + '/plugins/' + pluginName + '/' + npmVersion + '/static/images/../chocobo.png', + '/plugins/' + pluginName + '/' + npmVersion + '/client-scripts/../client/common-client-plugin.js', + '/themes/' + themeName + '/' + themeVersion + '/static/../images/chocobo.png', + '/themes/' + themeName + '/' + themeVersion + '/client-scripts/client/video-watch-client-plugin.js/..', + '/themes/' + themeName + '/' + themeVersion + '/css/../assets/style1.css' + ] + + for (const p of paths) { + await makeGetRequest({ url: server.url, path: p, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + }) + + it('Should fail with an unknown auth name', async function () { + const path = '/plugins/' + pluginName + '/' + npmVersion + '/auth/bad-auth' + + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with an unknown static file', async function () { + const paths = [ + '/plugins/' + pluginName + '/' + npmVersion + '/static/fake/chocobo.png', + '/plugins/' + pluginName + '/' + npmVersion + '/client-scripts/client/fake.js', + '/themes/' + themeName + '/' + themeVersion + '/static/fake/chocobo.png', + '/themes/' + themeName + '/' + themeVersion + '/client-scripts/client/fake.js' + ] + + for (const p of paths) { + await makeGetRequest({ url: server.url, path: p, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + }) + + it('Should fail with an unknown CSS file', async function () { + await makeGetRequest({ + url: server.url, + path: '/themes/' + themeName + '/' + themeVersion + '/css/assets/fake.css', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct parameters', async function () { + const paths = [ + '/plugins/' + pluginName + '/' + npmVersion + '/static/images/chocobo.png', + '/plugins/' + pluginName + '/' + npmVersion + '/client-scripts/client/common-client-plugin.js', + '/themes/' + themeName + '/' + themeVersion + '/static/images/chocobo.png', + '/themes/' + themeName + '/' + themeVersion + '/client-scripts/client/video-watch-client-plugin.js', + '/themes/' + themeName + '/' + themeVersion + '/css/assets/style1.css' + ] + + for (const p of paths) { + await makeGetRequest({ url: server.url, path: p, expectedStatus: HttpStatusCode.OK_200 }) + } + + const authPath = '/plugins/' + pluginName + '/' + npmVersion + '/auth/fake-auth' + await makeGetRequest({ url: server.url, path: authPath, expectedStatus: HttpStatusCode.FOUND_302 }) + }) + }) + + describe('When listing available plugins/themes', function () { + const path = '/api/v1/plugins/available' + const baseQuery = { + search: 'super search', + pluginType: PluginType.PLUGIN, + currentPeerTubeEngine: '1.2.3' + } + + it('Should fail with an invalid token', async function () { + await makeGetRequest({ + url: server.url, + path, + token: 'fake_token', + query: baseQuery, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + query: baseQuery, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an invalid plugin type', async function () { + const query = { ...baseQuery, pluginType: 5 } + + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query + }) + }) + + it('Should fail with an invalid current peertube engine', async function () { + const query = { ...baseQuery, currentPeerTubeEngine: '1.0' } + + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query + }) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query: baseQuery, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When listing local plugins/themes', function () { + const path = '/api/v1/plugins' + const baseQuery = { + pluginType: PluginType.THEME + } + + it('Should fail with an invalid token', async function () { + await makeGetRequest({ + url: server.url, + path, + token: 'fake_token', + query: baseQuery, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + query: baseQuery, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an invalid plugin type', async function () { + const query = { ...baseQuery, pluginType: 5 } + + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query + }) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query: baseQuery, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When getting a plugin or the registered settings or public settings', function () { + const path = '/api/v1/plugins/' + + it('Should fail with an invalid token', async function () { + for (const suffix of [ npmPlugin, `${npmPlugin}/registered-settings` ]) { + await makeGetRequest({ + url: server.url, + path: path + suffix, + token: 'fake_token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + } + }) + + it('Should fail if the user is not an administrator', async function () { + for (const suffix of [ npmPlugin, `${npmPlugin}/registered-settings` ]) { + await makeGetRequest({ + url: server.url, + path: path + suffix, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + }) + + it('Should fail with an invalid npm name', async function () { + for (const suffix of [ 'toto', 'toto/registered-settings', 'toto/public-settings' ]) { + await makeGetRequest({ + url: server.url, + path: path + suffix, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + } + + for (const suffix of [ 'peertube-plugin-TOTO', 'peertube-plugin-TOTO/registered-settings', 'peertube-plugin-TOTO/public-settings' ]) { + await makeGetRequest({ + url: server.url, + path: path + suffix, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + } + }) + + it('Should fail with an unknown plugin', async function () { + for (const suffix of [ 'peertube-plugin-toto', 'peertube-plugin-toto/registered-settings', 'peertube-plugin-toto/public-settings' ]) { + await makeGetRequest({ + url: server.url, + path: path + suffix, + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + } + }) + + it('Should succeed with the correct parameters', async function () { + for (const suffix of [ npmPlugin, `${npmPlugin}/registered-settings`, `${npmPlugin}/public-settings` ]) { + await makeGetRequest({ + url: server.url, + path: path + suffix, + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + } + }) + }) + + describe('When updating plugin settings', function () { + const path = '/api/v1/plugins/' + const settings = { setting1: 'value1' } + + it('Should fail with an invalid token', async function () { + await makePutBodyRequest({ + url: server.url, + path: path + npmPlugin + '/settings', + fields: { settings }, + token: 'fake_token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makePutBodyRequest({ + url: server.url, + path: path + npmPlugin + '/settings', + fields: { settings }, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid npm name', async function () { + await makePutBodyRequest({ + url: server.url, + path: path + 'toto/settings', + fields: { settings }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makePutBodyRequest({ + url: server.url, + path: path + 'peertube-plugin-TOTO/settings', + fields: { settings }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an unknown plugin', async function () { + await makePutBodyRequest({ + url: server.url, + path: path + 'peertube-plugin-toto/settings', + fields: { settings }, + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makePutBodyRequest({ + url: server.url, + path: path + npmPlugin + '/settings', + fields: { settings }, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When installing/updating/uninstalling a plugin', function () { + const path = '/api/v1/plugins/' + + it('Should fail with an invalid token', async function () { + for (const suffix of [ 'install', 'update', 'uninstall' ]) { + await makePostBodyRequest({ + url: server.url, + path: path + suffix, + fields: { npmName: npmPlugin }, + token: 'fake_token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + } + }) + + it('Should fail if the user is not an administrator', async function () { + for (const suffix of [ 'install', 'update', 'uninstall' ]) { + await makePostBodyRequest({ + url: server.url, + path: path + suffix, + fields: { npmName: npmPlugin }, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + }) + + it('Should fail with an invalid npm name', async function () { + for (const suffix of [ 'install', 'update', 'uninstall' ]) { + await makePostBodyRequest({ + url: server.url, + path: path + suffix, + fields: { npmName: 'toto' }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + } + + for (const suffix of [ 'install', 'update', 'uninstall' ]) { + await makePostBodyRequest({ + url: server.url, + path: path + suffix, + fields: { npmName: 'peertube-plugin-TOTO' }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + } + }) + + it('Should succeed with the correct parameters', async function () { + const it = [ + { suffix: 'install', status: HttpStatusCode.OK_200 }, + { suffix: 'update', status: HttpStatusCode.OK_200 }, + { suffix: 'uninstall', status: HttpStatusCode.NO_CONTENT_204 } + ] + + for (const obj of it) { + await makePostBodyRequest({ + url: server.url, + path: path + obj.suffix, + fields: { npmName: npmPlugin }, + token: server.accessToken, + expectedStatus: obj.status + }) + } + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/redundancy.ts b/packages/tests/src/api/check-params/redundancy.ts new file mode 100644 index 000000000..16a5d0a3d --- /dev/null +++ b/packages/tests/src/api/check-params/redundancy.ts @@ -0,0 +1,240 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + makePutBodyRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test server redundancy API validators', function () { + let servers: PeerTubeServer[] + let userAccessToken = null + let videoIdLocal: number + let videoRemote: VideoCreateResult + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(160000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await doubleFollow(servers[0], servers[1]) + + const user = { + username: 'user1', + password: 'password' + } + + await servers[0].users.create({ username: user.username, password: user.password }) + userAccessToken = await servers[0].login.getAccessToken(user) + + videoIdLocal = (await servers[0].videos.quickUpload({ name: 'video' })).id + + const remoteUUID = (await servers[1].videos.quickUpload({ name: 'video' })).uuid + + await waitJobs(servers) + + videoRemote = await servers[0].videos.get({ id: remoteUUID }) + }) + + describe('When listing redundancies', function () { + const path = '/api/v1/server/redundancy/videos' + + let url: string + let token: string + + before(function () { + url = servers[0].url + token = servers[0].accessToken + }) + + it('Should fail with an invalid token', async function () { + await makeGetRequest({ url, path, token: 'fake_token', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makeGetRequest({ url, path, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(url, path, servers[0].accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(url, path, servers[0].accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(url, path, servers[0].accessToken) + }) + + it('Should fail with a bad target', async function () { + await makeGetRequest({ url, path, token, query: { target: 'bad target' } }) + }) + + it('Should fail without target', async function () { + await makeGetRequest({ url, path, token }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ url, path, token, query: { target: 'my-videos' }, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When manually adding a redundancy', function () { + const path = '/api/v1/server/redundancy/videos' + + let url: string + let token: string + + before(function () { + url = servers[0].url + token = servers[0].accessToken + }) + + it('Should fail with an invalid token', async function () { + await makePostBodyRequest({ url, path, token: 'fake_token', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makePostBodyRequest({ url, path, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail without a video id', async function () { + await makePostBodyRequest({ url, path, token }) + }) + + it('Should fail with an incorrect video id', async function () { + await makePostBodyRequest({ url, path, token, fields: { videoId: 'peertube' } }) + }) + + it('Should fail with a not found video id', async function () { + await makePostBodyRequest({ url, path, token, fields: { videoId: 6565 }, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a local a video id', async function () { + await makePostBodyRequest({ url, path, token, fields: { videoId: videoIdLocal } }) + }) + + it('Should succeed with the correct params', async function () { + await makePostBodyRequest({ + url, + path, + token, + fields: { videoId: videoRemote.shortUUID }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + + it('Should fail if the video is already duplicated', async function () { + this.timeout(30000) + + await waitJobs(servers) + + await makePostBodyRequest({ + url, + path, + token, + fields: { videoId: videoRemote.uuid }, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + }) + + describe('When manually removing a redundancy', function () { + const path = '/api/v1/server/redundancy/videos/' + + let url: string + let token: string + + before(function () { + url = servers[0].url + token = servers[0].accessToken + }) + + it('Should fail with an invalid token', async function () { + await makeDeleteRequest({ url, path: path + '1', token: 'fake_token', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makeDeleteRequest({ url, path: path + '1', token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with an incorrect video id', async function () { + await makeDeleteRequest({ url, path: path + 'toto', token }) + }) + + it('Should fail with a not found video redundancy', async function () { + await makeDeleteRequest({ url, path: path + '454545', token, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('When updating server redundancy', function () { + const path = '/api/v1/server/redundancy' + + it('Should fail with an invalid token', async function () { + await makePutBodyRequest({ + url: servers[0].url, + path: path + '/' + servers[1].host, + fields: { redundancyAllowed: true }, + token: 'fake_token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if the user is not an administrator', async function () { + await makePutBodyRequest({ + url: servers[0].url, + path: path + '/' + servers[1].host, + fields: { redundancyAllowed: true }, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail if we do not follow this server', async function () { + await makePutBodyRequest({ + url: servers[0].url, + path: path + '/example.com', + fields: { redundancyAllowed: true }, + token: servers[0].accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail without de redundancyAllowed param', async function () { + await makePutBodyRequest({ + url: servers[0].url, + path: path + '/' + servers[1].host, + fields: { blabla: true }, + token: servers[0].accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makePutBodyRequest({ + url: servers[0].url, + path: path + '/' + servers[1].host, + fields: { redundancyAllowed: true }, + token: servers[0].accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/check-params/registrations.ts b/packages/tests/src/api/check-params/registrations.ts new file mode 100644 index 000000000..e4e46da2a --- /dev/null +++ b/packages/tests/src/api/check-params/registrations.ts @@ -0,0 +1,446 @@ +import { omit } from '@peertube/peertube-core-utils' +import { HttpStatusCode, HttpStatusCodeType, UserRole } from '@peertube/peertube-models' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { + cleanupTests, + createSingleServer, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar +} from '@peertube/peertube-server-commands' + +describe('Test registrations API validators', function () { + let server: PeerTubeServer + let userToken: string + let moderatorToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultAccountAvatar([ server ]) + await setDefaultChannelAvatar([ server ]) + + await server.config.enableSignup(false); + + ({ token: moderatorToken } = await server.users.generate('moderator', UserRole.MODERATOR)); + ({ token: userToken } = await server.users.generate('user', UserRole.USER)) + }) + + describe('Register', function () { + const registrationPath = '/api/v1/users/register' + const registrationRequestPath = '/api/v1/users/registrations/request' + + const baseCorrectParams = { + username: 'user3', + displayName: 'super user', + email: 'test3@example.com', + password: 'my super password', + registrationReason: 'my super registration reason' + } + + describe('When registering a new user or requesting user registration', function () { + + async function check (fields: any, expectedStatus: HttpStatusCodeType = HttpStatusCode.BAD_REQUEST_400) { + await server.config.enableSignup(false) + await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus }) + + await server.config.enableSignup(true) + await makePostBodyRequest({ url: server.url, path: registrationRequestPath, fields, expectedStatus }) + } + + it('Should fail with a too small username', async function () { + const fields = { ...baseCorrectParams, username: '' } + + await check(fields) + }) + + it('Should fail with a too long username', async function () { + const fields = { ...baseCorrectParams, username: 'super'.repeat(50) } + + await check(fields) + }) + + it('Should fail with an incorrect username', async function () { + const fields = { ...baseCorrectParams, username: 'my username' } + + await check(fields) + }) + + it('Should fail with a missing email', async function () { + const fields = omit(baseCorrectParams, [ 'email' ]) + + await check(fields) + }) + + it('Should fail with an invalid email', async function () { + const fields = { ...baseCorrectParams, email: 'test_example.com' } + + await check(fields) + }) + + it('Should fail with a too small password', async function () { + const fields = { ...baseCorrectParams, password: 'bla' } + + await check(fields) + }) + + it('Should fail with a too long password', async function () { + const fields = { ...baseCorrectParams, password: 'super'.repeat(61) } + + await check(fields) + }) + + it('Should fail if we register a user with the same username', async function () { + const fields = { ...baseCorrectParams, username: 'root' } + + await check(fields, HttpStatusCode.CONFLICT_409) + }) + + it('Should fail with a "peertube" username', async function () { + const fields = { ...baseCorrectParams, username: 'peertube' } + + await check(fields, HttpStatusCode.CONFLICT_409) + }) + + it('Should fail if we register a user with the same email', async function () { + const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' } + + await check(fields, HttpStatusCode.CONFLICT_409) + }) + + it('Should fail with a bad display name', async function () { + const fields = { ...baseCorrectParams, displayName: 'a'.repeat(150) } + + await check(fields) + }) + + it('Should fail with a bad channel name', async function () { + const fields = { ...baseCorrectParams, channel: { name: '[]azf', displayName: 'toto' } } + + await check(fields) + }) + + it('Should fail with a bad channel display name', async function () { + const fields = { ...baseCorrectParams, channel: { name: 'toto', displayName: '' } } + + await check(fields) + }) + + it('Should fail with a channel name that is the same as username', async function () { + const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } } + const fields = { ...baseCorrectParams, ...source } + + await check(fields) + }) + + it('Should fail with an existing channel', async function () { + const attributes = { name: 'existing_channel', displayName: 'hello', description: 'super description' } + await server.channels.create({ attributes }) + + const fields = { ...baseCorrectParams, channel: { name: 'existing_channel', displayName: 'toto' } } + + await check(fields, HttpStatusCode.CONFLICT_409) + }) + + it('Should fail on a server with registration disabled', async function () { + this.timeout(60000) + + await server.config.updateExistingSubConfig({ + newConfig: { + signup: { + enabled: false + } + } + }) + + await server.registrations.register({ username: 'user4', expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await server.registrations.requestRegistration({ + username: 'user4', + registrationReason: 'reason', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail if the user limit is reached', async function () { + this.timeout(60000) + + const { total } = await server.users.list() + + await server.config.enableSignup(false, total) + await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + + await server.config.enableSignup(true, total) + await server.registrations.requestRegistration({ + username: 'user42', + registrationReason: 'reason', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed if the user limit is not reached', async function () { + this.timeout(60000) + + const { total } = await server.users.list() + + await server.config.enableSignup(false, total + 1) + await server.registrations.register({ username: 'user43', expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + + await server.config.enableSignup(true, total + 2) + await server.registrations.requestRegistration({ + username: 'user44', + registrationReason: 'reason', + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('On direct registration', function () { + + it('Should succeed with the correct params', async function () { + await server.config.enableSignup(false) + + const fields = { + username: 'user_direct_1', + displayName: 'super user direct 1', + email: 'user_direct_1@example.com', + password: 'my super password', + channel: { name: 'super_user_direct_1_channel', displayName: 'super user direct 1 channel' } + } + + await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + }) + + it('Should fail if the instance requires approval', async function () { + this.timeout(60000) + + await server.config.enableSignup(true) + await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + }) + + describe('On registration request', function () { + + before(async function () { + this.timeout(60000) + + await server.config.enableSignup(true) + }) + + it('Should fail with an invalid registration reason', async function () { + for (const registrationReason of [ '', 't', 't'.repeat(5000) ]) { + await server.registrations.requestRegistration({ + username: 'user_request_1', + registrationReason, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + } + }) + + it('Should succeed with the correct params', async function () { + await server.registrations.requestRegistration({ + username: 'user_request_2', + registrationReason: 'tt', + channel: { + displayName: 'my user request 2 channel', + name: 'user_request_2_channel' + } + }) + }) + + it('Should fail if the username is already awaiting registration approval', async function () { + await server.registrations.requestRegistration({ + username: 'user_request_2', + registrationReason: 'tt', + channel: { + displayName: 'my user request 42 channel', + name: 'user_request_42_channel' + }, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should fail if the email is already awaiting registration approval', async function () { + await server.registrations.requestRegistration({ + username: 'user42', + email: 'user_request_2@example.com', + registrationReason: 'tt', + channel: { + displayName: 'my user request 42 channel', + name: 'user_request_42_channel' + }, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should fail if the channel is already awaiting registration approval', async function () { + await server.registrations.requestRegistration({ + username: 'user42', + registrationReason: 'tt', + channel: { + displayName: 'my user request 2 channel', + name: 'user_request_2_channel' + }, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should fail if the instance does not require approval', async function () { + this.timeout(60000) + + await server.config.enableSignup(false) + + await server.registrations.requestRegistration({ + username: 'user42', + registrationReason: 'toto', + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + }) + + describe('Registrations accept/reject', function () { + let id1: number + let id2: number + + before(async function () { + this.timeout(60000) + + await server.config.enableSignup(true); + + ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_2', registrationReason: 'toto' })); + ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_3', registrationReason: 'toto' })) + }) + + it('Should fail to accept/reject registration without token', async function () { + const options = { id: id1, moderationResponse: 'tt', token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 } + await server.registrations.accept(options) + await server.registrations.reject(options) + }) + + it('Should fail to accept/reject registration with a non moderator user', async function () { + const options = { id: id1, moderationResponse: 'tt', token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 } + await server.registrations.accept(options) + await server.registrations.reject(options) + }) + + it('Should fail to accept/reject registration with a bad registration id', async function () { + { + const options = { id: 't' as any, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 } + await server.registrations.accept(options) + await server.registrations.reject(options) + } + + { + const options = { id: 42, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 } + await server.registrations.accept(options) + await server.registrations.reject(options) + } + }) + + it('Should fail to accept/reject registration with a bad moderation resposne', async function () { + for (const moderationResponse of [ '', 't', 't'.repeat(5000) ]) { + const options = { id: id1, moderationResponse, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 } + await server.registrations.accept(options) + await server.registrations.reject(options) + } + }) + + it('Should succeed to accept a registration', async function () { + await server.registrations.accept({ id: id1, moderationResponse: 'tt', token: moderatorToken }) + }) + + it('Should succeed to reject a registration', async function () { + await server.registrations.reject({ id: id2, moderationResponse: 'tt', token: moderatorToken }) + }) + + it('Should fail to accept/reject a registration that was already accepted/rejected', async function () { + for (const id of [ id1, id2 ]) { + const options = { id, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.CONFLICT_409 } + await server.registrations.accept(options) + await server.registrations.reject(options) + } + }) + }) + + describe('Registrations deletion', function () { + let id1: number + let id2: number + let id3: number + + before(async function () { + ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_4', registrationReason: 'toto' })); + ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_5', registrationReason: 'toto' })); + ({ id: id3 } = await server.registrations.requestRegistration({ username: 'request_6', registrationReason: 'toto' })) + + await server.registrations.accept({ id: id2, moderationResponse: 'tt' }) + await server.registrations.reject({ id: id3, moderationResponse: 'tt' }) + }) + + it('Should fail to delete registration without token', async function () { + await server.registrations.delete({ id: id1, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail to delete registration with a non moderator user', async function () { + await server.registrations.delete({ id: id1, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail to delete registration with a bad registration id', async function () { + await server.registrations.delete({ id: 't' as any, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.registrations.delete({ id: 42, token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await server.registrations.delete({ id: id1, token: moderatorToken }) + await server.registrations.delete({ id: id2, token: moderatorToken }) + await server.registrations.delete({ id: id3, token: moderatorToken }) + }) + }) + + describe('Listing registrations', function () { + const path = '/api/v1/users/registrations' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await server.registrations.list({ + token: null, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await server.registrations.list({ + token: userToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + await server.registrations.list({ + token: moderatorToken, + search: 'toto' + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/runners.ts b/packages/tests/src/api/check-params/runners.ts new file mode 100644 index 000000000..dd2d2f0a1 --- /dev/null +++ b/packages/tests/src/api/check-params/runners.ts @@ -0,0 +1,911 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { basename } from 'path' +import { + HttpStatusCode, + HttpStatusCodeType, + isVideoStudioTaskIntro, + RunnerJob, + RunnerJobState, + RunnerJobStudioTranscodingPayload, + RunnerJobSuccessPayload, + RunnerJobUpdatePayload, + VideoPrivacy, + VideoStudioTaskIntro +} from '@peertube/peertube-models' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { + cleanupTests, + createSingleServer, + makePostBodyRequest, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + VideoStudioCommand, + waitJobs +} from '@peertube/peertube-server-commands' + +const badUUID = '910ec12a-d9e6-458b-a274-0abb655f9464' + +describe('Test managing runners', function () { + let server: PeerTubeServer + + let userToken: string + + let registrationTokenId: number + let registrationToken: string + + let runnerToken: string + let runnerToken2: string + + let completedJobToken: string + let completedJobUUID: string + + let cancelledJobToken: string + let cancelledJobUUID: string + + before(async function () { + this.timeout(120000) + + const config = { + rates_limit: { + api: { + max: 5000 + } + } + } + + server = await createSingleServer(1, config) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + userToken = await server.users.generateUserAndToken('user1') + + const { data } = await server.runnerRegistrationTokens.list() + registrationToken = data[0].registrationToken + registrationTokenId = data[0].id + + await server.config.enableTranscoding({ hls: true, webVideo: true }) + await server.config.enableStudio() + await server.config.enableRemoteTranscoding() + await server.config.enableRemoteStudio() + + runnerToken = await server.runners.autoRegisterRunner() + runnerToken2 = await server.runners.autoRegisterRunner() + + { + await server.videos.quickUpload({ name: 'video 1' }) + await server.videos.quickUpload({ name: 'video 2' }) + + await waitJobs([ server ]) + + { + const job = await server.runnerJobs.autoProcessWebVideoJob(runnerToken) + completedJobToken = job.jobToken + completedJobUUID = job.uuid + } + + { + const { job } = await server.runnerJobs.autoAccept({ runnerToken }) + cancelledJobToken = job.jobToken + cancelledJobUUID = job.uuid + await server.runnerJobs.cancelByAdmin({ jobUUID: cancelledJobUUID }) + } + } + }) + + describe('Managing runner registration tokens', function () { + + describe('Common', function () { + + it('Should fail to generate, list or delete runner registration token without oauth token', async function () { + const expectedStatus = HttpStatusCode.UNAUTHORIZED_401 + + await server.runnerRegistrationTokens.generate({ token: null, expectedStatus }) + await server.runnerRegistrationTokens.list({ token: null, expectedStatus }) + await server.runnerRegistrationTokens.delete({ token: null, id: registrationTokenId, expectedStatus }) + }) + + it('Should fail to generate, list or delete runner registration token without admin rights', async function () { + const expectedStatus = HttpStatusCode.FORBIDDEN_403 + + await server.runnerRegistrationTokens.generate({ token: userToken, expectedStatus }) + await server.runnerRegistrationTokens.list({ token: userToken, expectedStatus }) + await server.runnerRegistrationTokens.delete({ token: userToken, id: registrationTokenId, expectedStatus }) + }) + }) + + describe('Delete', function () { + + it('Should fail to delete with a bad id', async function () { + await server.runnerRegistrationTokens.delete({ id: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('List', function () { + const path = '/api/v1/runners/registration-tokens' + + it('Should fail to list with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail to list with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail to list with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should succeed to list with the correct params', async function () { + await server.runnerRegistrationTokens.list({ start: 0, count: 5, sort: '-createdAt' }) + }) + }) + }) + + describe('Managing runners', function () { + let toDeleteId: number + + describe('Register', function () { + const name = 'runner name' + + it('Should fail with a bad registration token', async function () { + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + + await server.runners.register({ name, registrationToken: 'a'.repeat(4000), expectedStatus }) + await server.runners.register({ name, registrationToken: null, expectedStatus }) + }) + + it('Should fail with an unknown registration token', async function () { + await server.runners.register({ name, registrationToken: 'aaa', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a bad name', async function () { + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + + await server.runners.register({ name: '', registrationToken, expectedStatus }) + await server.runners.register({ name: 'a'.repeat(200), registrationToken, expectedStatus }) + }) + + it('Should fail with an invalid description', async function () { + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + + await server.runners.register({ name, description: '', registrationToken, expectedStatus }) + await server.runners.register({ name, description: 'a'.repeat(5000), registrationToken, expectedStatus }) + }) + + it('Should succeed with the correct params', async function () { + const { id } = await server.runners.register({ name, description: 'super description', registrationToken }) + + toDeleteId = id + }) + + it('Should fail with the same runner name', async function () { + await server.runners.register({ + name, + description: 'super description', + registrationToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + describe('Delete', function () { + + it('Should fail without oauth token', async function () { + await server.runners.delete({ token: null, id: toDeleteId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail without admin rights', async function () { + await server.runners.delete({ token: userToken, id: toDeleteId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad id', async function () { + await server.runners.delete({ id: 'hi' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown id', async function () { + await server.runners.delete({ id: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await server.runners.delete({ id: toDeleteId }) + }) + }) + + describe('List', function () { + const path = '/api/v1/runners' + + it('Should fail without oauth token', async function () { + await server.runners.list({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail without admin rights', async function () { + await server.runners.list({ token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail to list with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail to list with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail to list with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an invalid state', async function () { + await server.runners.list({ start: 0, count: 5, sort: '-createdAt' }) + }) + + it('Should succeed to list with the correct params', async function () { + await server.runners.list({ start: 0, count: 5, sort: '-createdAt' }) + }) + }) + + }) + + describe('Runner jobs by admin', function () { + + describe('Cancel', function () { + let jobUUID: string + + before(async function () { + this.timeout(60000) + + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + jobUUID = availableJobs[0].uuid + }) + + it('Should fail without oauth token', async function () { + await server.runnerJobs.cancelByAdmin({ token: null, jobUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail without admin rights', async function () { + await server.runnerJobs.cancelByAdmin({ token: userToken, jobUUID, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad job uuid', async function () { + await server.runnerJobs.cancelByAdmin({ jobUUID: 'hello', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown job uuid', async function () { + const jobUUID = badUUID + await server.runnerJobs.cancelByAdmin({ jobUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with an already cancelled job', async function () { + await server.runnerJobs.cancelByAdmin({ jobUUID: cancelledJobUUID, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with the correct params', async function () { + await server.runnerJobs.cancelByAdmin({ jobUUID }) + }) + }) + + describe('List', function () { + const path = '/api/v1/runners/jobs' + + it('Should fail without oauth token', async function () { + await server.runnerJobs.list({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail without admin rights', async function () { + await server.runnerJobs.list({ token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail to list with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail to list with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail to list with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an invalid state', async function () { + await server.runnerJobs.list({ start: 0, count: 5, sort: '-createdAt', stateOneOf: 42 as any }) + await server.runnerJobs.list({ start: 0, count: 5, sort: '-createdAt', stateOneOf: [ 42 ] as any }) + }) + + it('Should succeed with the correct params', async function () { + await server.runnerJobs.list({ start: 0, count: 5, sort: '-createdAt', stateOneOf: [ RunnerJobState.COMPLETED ] }) + }) + }) + + describe('Delete', function () { + let jobUUID: string + + before(async function () { + this.timeout(60000) + + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + jobUUID = availableJobs[0].uuid + }) + + it('Should fail without oauth token', async function () { + await server.runnerJobs.deleteByAdmin({ token: null, jobUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail without admin rights', async function () { + await server.runnerJobs.deleteByAdmin({ token: userToken, jobUUID, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad job uuid', async function () { + await server.runnerJobs.deleteByAdmin({ jobUUID: 'hello', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown job uuid', async function () { + const jobUUID = badUUID + await server.runnerJobs.deleteByAdmin({ jobUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await server.runnerJobs.deleteByAdmin({ jobUUID }) + }) + }) + + }) + + describe('Runner jobs by runners', function () { + let jobUUID: string + let jobToken: string + let videoUUID: string + + let jobUUID2: string + let jobToken2: string + + let videoUUID2: string + + let pendingUUID: string + + let videoStudioUUID: string + let studioFile: string + + let liveAcceptedJob: RunnerJob & { jobToken: string } + let studioAcceptedJob: RunnerJob & { jobToken: string } + + async function fetchVideoInputFiles (options: { + jobUUID: string + videoUUID: string + runnerToken: string + jobToken: string + expectedStatus: HttpStatusCodeType + }) { + const { jobUUID, expectedStatus, videoUUID, runnerToken, jobToken } = options + + const basePath = '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + const paths = [ `${basePath}/max-quality`, `${basePath}/previews/max-quality` ] + + for (const path of paths) { + await makePostBodyRequest({ url: server.url, path, fields: { runnerToken, jobToken }, expectedStatus }) + } + } + + async function fetchStudioFiles (options: { + jobUUID: string + videoUUID: string + runnerToken: string + jobToken: string + studioFile?: string + expectedStatus: HttpStatusCodeType + }) { + const { jobUUID, expectedStatus, videoUUID, runnerToken, jobToken, studioFile } = options + + const path = `/api/v1/runners/jobs/${jobUUID}/files/videos/${videoUUID}/studio/task-files/${studioFile}` + + await makePostBodyRequest({ url: server.url, path, fields: { runnerToken, jobToken }, expectedStatus }) + } + + before(async function () { + this.timeout(120000) + + { + await server.runnerJobs.cancelAllJobs({ state: RunnerJobState.PENDING }) + } + + { + const { uuid } = await server.videos.quickUpload({ name: 'video' }) + videoUUID = uuid + + await waitJobs([ server ]) + + const { job } = await server.runnerJobs.autoAccept({ runnerToken }) + jobUUID = job.uuid + jobToken = job.jobToken + } + + { + const { uuid } = await server.videos.quickUpload({ name: 'video' }) + videoUUID2 = uuid + + await waitJobs([ server ]) + + const { job } = await server.runnerJobs.autoAccept({ runnerToken: runnerToken2 }) + jobUUID2 = job.uuid + jobToken2 = job.jobToken + } + + { + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + pendingUUID = availableJobs[0].uuid + } + + { + await server.config.disableTranscoding() + + const { uuid } = await server.videos.quickUpload({ name: 'video studio' }) + videoStudioUUID = uuid + + await server.config.enableTranscoding({ hls: true, webVideo: true }) + await server.config.enableStudio() + + await server.videoStudio.createEditionTasks({ + videoId: videoStudioUUID, + tasks: VideoStudioCommand.getComplexTask() + }) + + const { job } = await server.runnerJobs.autoAccept({ runnerToken, type: 'video-studio-transcoding' }) + studioAcceptedJob = job + + const tasks = (job.payload as RunnerJobStudioTranscodingPayload).tasks + const fileUrl = (tasks.find(t => isVideoStudioTaskIntro(t)) as VideoStudioTaskIntro).options.file as string + studioFile = basename(fileUrl) + } + + { + await server.config.enableLive({ + allowReplay: false, + resolutions: 'max', + transcoding: true + }) + + const { live } = await server.live.quickCreate({ permanentLive: true, saveReplay: false, privacy: VideoPrivacy.PUBLIC }) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await waitJobs([ server ]) + + await server.runnerJobs.requestLiveJob(runnerToken) + + const { job } = await server.runnerJobs.autoAccept({ runnerToken, type: 'live-rtmp-hls-transcoding' }) + liveAcceptedJob = job + + await stopFfmpeg(ffmpegCommand) + } + }) + + describe('Common runner tokens validations', function () { + + async function testEndpoints (options: { + jobUUID: string + runnerToken: string + jobToken: string + expectedStatus: HttpStatusCodeType + }) { + await server.runnerJobs.abort({ ...options, reason: 'reason' }) + await server.runnerJobs.update({ ...options }) + await server.runnerJobs.error({ ...options, message: 'message' }) + await server.runnerJobs.success({ ...options, payload: { videoFile: 'video_short.mp4' } }) + } + + it('Should fail with an invalid job uuid', async function () { + const options = { jobUUID: 'a', runnerToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 } + + await testEndpoints({ ...options, jobToken }) + await fetchVideoInputFiles({ ...options, videoUUID, jobToken }) + await fetchStudioFiles({ ...options, videoUUID, jobToken: studioAcceptedJob.jobToken, studioFile }) + }) + + it('Should fail with an unknown job uuid', async function () { + const options = { jobUUID: badUUID, runnerToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 } + + await testEndpoints({ ...options, jobToken }) + await fetchVideoInputFiles({ ...options, videoUUID, jobToken }) + await fetchStudioFiles({ ...options, jobToken: studioAcceptedJob.jobToken, videoUUID, studioFile }) + }) + + it('Should fail with an invalid runner token', async function () { + const options = { runnerToken: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 } + + await testEndpoints({ ...options, jobUUID, jobToken }) + await fetchVideoInputFiles({ ...options, jobUUID, videoUUID, jobToken }) + await fetchStudioFiles({ + ...options, + jobToken: studioAcceptedJob.jobToken, + jobUUID: studioAcceptedJob.uuid, + videoUUID: videoStudioUUID, + studioFile + }) + }) + + it('Should fail with an unknown runner token', async function () { + const options = { runnerToken: badUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 } + + await testEndpoints({ ...options, jobUUID, jobToken }) + await fetchVideoInputFiles({ ...options, jobUUID, videoUUID, jobToken }) + await fetchStudioFiles({ + ...options, + jobToken: studioAcceptedJob.jobToken, + jobUUID: studioAcceptedJob.uuid, + videoUUID: videoStudioUUID, + studioFile + }) + }) + + it('Should fail with an invalid job token job uuid', async function () { + const options = { runnerToken, jobToken: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 } + + await testEndpoints({ ...options, jobUUID }) + await fetchVideoInputFiles({ ...options, jobUUID, videoUUID }) + await fetchStudioFiles({ ...options, jobUUID: studioAcceptedJob.uuid, videoUUID: videoStudioUUID, studioFile }) + }) + + it('Should fail with an unknown job token job uuid', async function () { + const options = { runnerToken, jobToken: badUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 } + + await testEndpoints({ ...options, jobUUID }) + await fetchVideoInputFiles({ ...options, jobUUID, videoUUID }) + await fetchStudioFiles({ ...options, jobUUID: studioAcceptedJob.uuid, videoUUID: videoStudioUUID, studioFile }) + }) + + it('Should fail with a runner token not associated to this job', async function () { + const options = { runnerToken: runnerToken2, expectedStatus: HttpStatusCode.NOT_FOUND_404 } + + await testEndpoints({ ...options, jobUUID, jobToken }) + await fetchVideoInputFiles({ ...options, jobUUID, videoUUID, jobToken }) + await fetchStudioFiles({ + ...options, + jobToken: studioAcceptedJob.jobToken, + jobUUID: studioAcceptedJob.uuid, + videoUUID: videoStudioUUID, + studioFile + }) + }) + + it('Should fail with a job uuid not associated to the job token', async function () { + { + const options = { jobUUID: jobUUID2, runnerToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 } + + await testEndpoints({ ...options, jobToken }) + await fetchVideoInputFiles({ ...options, jobToken, videoUUID }) + await fetchStudioFiles({ ...options, jobToken: studioAcceptedJob.jobToken, videoUUID: videoStudioUUID, studioFile }) + } + + { + const options = { runnerToken, jobToken: jobToken2, expectedStatus: HttpStatusCode.NOT_FOUND_404 } + + await testEndpoints({ ...options, jobUUID }) + await fetchVideoInputFiles({ ...options, jobUUID, videoUUID }) + await fetchStudioFiles({ ...options, jobUUID: studioAcceptedJob.uuid, videoUUID: videoStudioUUID, studioFile }) + } + }) + }) + + describe('Unregister', function () { + + it('Should fail without a runner token', async function () { + await server.runners.unregister({ runnerToken: null, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a bad a runner token', async function () { + await server.runners.unregister({ runnerToken: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown runner token', async function () { + await server.runners.unregister({ runnerToken: badUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('Request', function () { + + it('Should fail without a runner token', async function () { + await server.runnerJobs.request({ runnerToken: null, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a bad a runner token', async function () { + await server.runnerJobs.request({ runnerToken: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown runner token', async function () { + await server.runnerJobs.request({ runnerToken: badUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('Accept', function () { + + it('Should fail with a bad a job uuid', async function () { + await server.runnerJobs.accept({ jobUUID: '', runnerToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown job uuid', async function () { + await server.runnerJobs.accept({ jobUUID: badUUID, runnerToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a job not in pending state', async function () { + await server.runnerJobs.accept({ jobUUID: completedJobUUID, runnerToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.runnerJobs.accept({ jobUUID: cancelledJobUUID, runnerToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail without a runner token', async function () { + await server.runnerJobs.accept({ jobUUID: pendingUUID, runnerToken: null, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a bad a runner token', async function () { + await server.runnerJobs.accept({ jobUUID: pendingUUID, runnerToken: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown runner token', async function () { + await server.runnerJobs.accept({ jobUUID: pendingUUID, runnerToken: badUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('Abort', function () { + + it('Should fail without a reason', async function () { + await server.runnerJobs.abort({ jobUUID, jobToken, runnerToken, reason: null, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a bad reason', async function () { + const reason = 'reason'.repeat(5000) + await server.runnerJobs.abort({ jobUUID, jobToken, runnerToken, reason, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a job not in processing state', async function () { + await server.runnerJobs.abort({ + jobUUID: completedJobUUID, + jobToken: completedJobToken, + runnerToken, + reason: 'reason', + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + describe('Update', function () { + + describe('Common', function () { + + it('Should fail with an invalid progress', async function () { + await server.runnerJobs.update({ jobUUID, jobToken, runnerToken, progress: 101, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a job not in processing state', async function () { + await server.runnerJobs.update({ + jobUUID: cancelledJobUUID, + jobToken: cancelledJobToken, + runnerToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + }) + + describe('Live RTMP to HLS', function () { + const base: RunnerJobUpdatePayload = { + masterPlaylistFile: 'live/master.m3u8', + resolutionPlaylistFilename: '0.m3u8', + resolutionPlaylistFile: 'live/1.m3u8', + type: 'add-chunk', + videoChunkFile: 'live/1-000069.ts', + videoChunkFilename: '1-000068.ts' + } + + function testUpdate (payload: RunnerJobUpdatePayload) { + return server.runnerJobs.update({ + jobUUID: liveAcceptedJob.uuid, + jobToken: liveAcceptedJob.jobToken, + payload, + runnerToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + } + + it('Should fail with an invalid resolutionPlaylistFilename', async function () { + await testUpdate({ ...base, resolutionPlaylistFilename: undefined }) + await testUpdate({ ...base, resolutionPlaylistFilename: 'coucou/hello' }) + await testUpdate({ ...base, resolutionPlaylistFilename: 'hello' }) + }) + + it('Should fail with an invalid videoChunkFilename', async function () { + await testUpdate({ ...base, resolutionPlaylistFilename: undefined }) + await testUpdate({ ...base, resolutionPlaylistFilename: 'coucou/hello' }) + await testUpdate({ ...base, resolutionPlaylistFilename: 'hello' }) + }) + + it('Should fail with an invalid type', async function () { + await testUpdate({ ...base, type: undefined }) + await testUpdate({ ...base, type: 'toto' as any }) + }) + }) + }) + + describe('Error', function () { + + it('Should fail with a missing error message', async function () { + await server.runnerJobs.error({ jobUUID, jobToken, runnerToken, message: null, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an invalid error messgae', async function () { + const message = 'a'.repeat(6000) + await server.runnerJobs.error({ jobUUID, jobToken, runnerToken, message, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a job not in processing state', async function () { + await server.runnerJobs.error({ + jobUUID: completedJobUUID, + jobToken: completedJobToken, + message: 'my message', + runnerToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + describe('Success', function () { + let vodJobUUID: string + let vodJobToken: string + + describe('Common', function () { + + it('Should fail with a job not in processing state', async function () { + await server.runnerJobs.success({ + jobUUID: completedJobUUID, + jobToken: completedJobToken, + payload: { videoFile: 'video_short.mp4' }, + runnerToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + describe('VOD', function () { + + it('Should fail with an invalid vod web video payload', async function () { + const { job } = await server.runnerJobs.autoAccept({ runnerToken, type: 'vod-web-video-transcoding' }) + + await server.runnerJobs.success({ + jobUUID: job.uuid, + jobToken: job.jobToken, + payload: { hello: 'video_short.mp4' } as any, + runnerToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + vodJobUUID = job.uuid + vodJobToken = job.jobToken + }) + + it('Should fail with an invalid vod hls payload', async function () { + // To create HLS jobs + const payload: RunnerJobSuccessPayload = { videoFile: 'video_short.mp4' } + await server.runnerJobs.success({ runnerToken, jobUUID: vodJobUUID, jobToken: vodJobToken, payload }) + + await waitJobs([ server ]) + + const { job } = await server.runnerJobs.autoAccept({ runnerToken, type: 'vod-hls-transcoding' }) + + await server.runnerJobs.success({ + jobUUID: job.uuid, + jobToken: job.jobToken, + payload: { videoFile: 'video_short.mp4' } as any, + runnerToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an invalid vod audio merge payload', async function () { + const attributes = { name: 'audio_with_preview', previewfile: 'custom-preview.jpg', fixture: 'sample.ogg' } + await server.videos.upload({ attributes, mode: 'legacy' }) + + await waitJobs([ server ]) + + const { job } = await server.runnerJobs.autoAccept({ runnerToken, type: 'vod-audio-merge-transcoding' }) + + await server.runnerJobs.success({ + jobUUID: job.uuid, + jobToken: job.jobToken, + payload: { hello: 'video_short.mp4' } as any, + runnerToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + describe('Video studio', function () { + + it('Should fail with an invalid video studio transcoding payload', async function () { + await server.runnerJobs.success({ + jobUUID: studioAcceptedJob.uuid, + jobToken: studioAcceptedJob.jobToken, + payload: { hello: 'video_short.mp4' } as any, + runnerToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + }) + + describe('Job files', function () { + + describe('Check video param for common job file routes', function () { + + async function fetchFiles (options: { + videoUUID?: string + expectedStatus: HttpStatusCodeType + }) { + await fetchVideoInputFiles({ videoUUID, ...options, jobToken, jobUUID, runnerToken }) + + await fetchStudioFiles({ + videoUUID: videoStudioUUID, + + ...options, + + jobToken: studioAcceptedJob.jobToken, + jobUUID: studioAcceptedJob.uuid, + runnerToken, + studioFile + }) + } + + it('Should fail with an invalid video id', async function () { + await fetchFiles({ + videoUUID: 'a', + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an unknown video id', async function () { + const videoUUID = '910ec12a-d9e6-458b-a274-0abb655f9464' + + await fetchFiles({ + videoUUID, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a video id not associated to this job', async function () { + await fetchFiles({ + videoUUID: videoUUID2, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + await fetchFiles({ expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('Video studio tasks file routes', function () { + + it('Should fail with an invalid studio filename', async function () { + await fetchStudioFiles({ + videoUUID: videoStudioUUID, + jobUUID: studioAcceptedJob.uuid, + runnerToken, + jobToken: studioAcceptedJob.jobToken, + studioFile: 'toto', + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/search.ts b/packages/tests/src/api/check-params/search.ts new file mode 100644 index 000000000..b886cbc82 --- /dev/null +++ b/packages/tests/src/api/check-params/search.ts @@ -0,0 +1,278 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode } from '@peertube/peertube-models' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +function updateSearchIndex (server: PeerTubeServer, enabled: boolean, disableLocalSearch = false) { + return server.config.updateCustomSubConfig({ + newConfig: { + search: { + searchIndex: { + enabled, + disableLocalSearch + } + } + } + }) +} + +describe('Test videos API validator', function () { + let server: PeerTubeServer + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + }) + + describe('When searching videos', function () { + const path = '/api/v1/search/videos/' + + const query = { + search: 'coucou' + } + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, null, query) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, null, query) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, null, query) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path, query, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should fail with an invalid category', async function () { + const customQuery1 = { ...query, categoryOneOf: [ 'aa', 'b' ] } + await makeGetRequest({ url: server.url, path, query: customQuery1, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + const customQuery2 = { ...query, categoryOneOf: 'a' } + await makeGetRequest({ url: server.url, path, query: customQuery2, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with a valid category', async function () { + const customQuery1 = { ...query, categoryOneOf: [ 1, 7 ] } + await makeGetRequest({ url: server.url, path, query: customQuery1, expectedStatus: HttpStatusCode.OK_200 }) + + const customQuery2 = { ...query, categoryOneOf: 1 } + await makeGetRequest({ url: server.url, path, query: customQuery2, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should fail with an invalid licence', async function () { + const customQuery1 = { ...query, licenceOneOf: [ 'aa', 'b' ] } + await makeGetRequest({ url: server.url, path, query: customQuery1, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + const customQuery2 = { ...query, licenceOneOf: 'a' } + await makeGetRequest({ url: server.url, path, query: customQuery2, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with a valid licence', async function () { + const customQuery1 = { ...query, licenceOneOf: [ 1, 2 ] } + await makeGetRequest({ url: server.url, path, query: customQuery1, expectedStatus: HttpStatusCode.OK_200 }) + + const customQuery2 = { ...query, licenceOneOf: 1 } + await makeGetRequest({ url: server.url, path, query: customQuery2, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should succeed with a valid language', async function () { + const customQuery1 = { ...query, languageOneOf: [ 'fr', 'en' ] } + await makeGetRequest({ url: server.url, path, query: customQuery1, expectedStatus: HttpStatusCode.OK_200 }) + + const customQuery2 = { ...query, languageOneOf: 'fr' } + await makeGetRequest({ url: server.url, path, query: customQuery2, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should succeed with valid tags', async function () { + const customQuery1 = { ...query, tagsOneOf: [ 'tag1', 'tag2' ] } + await makeGetRequest({ url: server.url, path, query: customQuery1, expectedStatus: HttpStatusCode.OK_200 }) + + const customQuery2 = { ...query, tagsOneOf: 'tag1' } + await makeGetRequest({ url: server.url, path, query: customQuery2, expectedStatus: HttpStatusCode.OK_200 }) + + const customQuery3 = { ...query, tagsAllOf: [ 'tag1', 'tag2' ] } + await makeGetRequest({ url: server.url, path, query: customQuery3, expectedStatus: HttpStatusCode.OK_200 }) + + const customQuery4 = { ...query, tagsAllOf: 'tag1' } + await makeGetRequest({ url: server.url, path, query: customQuery4, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should fail with invalid durations', async function () { + const customQuery1 = { ...query, durationMin: 'hello' } + await makeGetRequest({ url: server.url, path, query: customQuery1, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + const customQuery2 = { ...query, durationMax: 'hello' } + await makeGetRequest({ url: server.url, path, query: customQuery2, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with invalid dates', async function () { + const customQuery1 = { ...query, startDate: 'hello' } + await makeGetRequest({ url: server.url, path, query: customQuery1, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + const customQuery2 = { ...query, endDate: 'hello' } + await makeGetRequest({ url: server.url, path, query: customQuery2, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + const customQuery3 = { ...query, originallyPublishedStartDate: 'hello' } + await makeGetRequest({ url: server.url, path, query: customQuery3, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + const customQuery4 = { ...query, originallyPublishedEndDate: 'hello' } + await makeGetRequest({ url: server.url, path, query: customQuery4, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an invalid host', async function () { + const customQuery = { ...query, host: '6565' } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with a host', async function () { + const customQuery = { ...query, host: 'example.com' } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should fail with invalid uuids', async function () { + const customQuery = { ...query, uuids: [ '6565', 'dfd70b83-639f-4980-94af-304a56ab4b35' ] } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with valid uuids', async function () { + const customQuery = { ...query, uuids: [ 'dfd70b83-639f-4980-94af-304a56ab4b35' ] } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When searching video playlists', function () { + const path = '/api/v1/search/video-playlists/' + + const query = { + search: 'coucou', + host: 'example.com' + } + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, null, query) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, null, query) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, null, query) + }) + + it('Should fail with an invalid host', async function () { + await makeGetRequest({ url: server.url, path, query: { ...query, host: '6565' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with invalid uuids', async function () { + const customQuery = { ...query, uuids: [ '6565', 'dfd70b83-639f-4980-94af-304a56ab4b35' ] } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path, query, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When searching video channels', function () { + const path = '/api/v1/search/video-channels/' + + const query = { + search: 'coucou', + host: 'example.com' + } + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, null, query) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, null, query) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, null, query) + }) + + it('Should fail with an invalid host', async function () { + await makeGetRequest({ url: server.url, path, query: { ...query, host: '6565' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with invalid handles', async function () { + await makeGetRequest({ url: server.url, path, query: { ...query, handles: [ '' ] }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path, query, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('Search target', function () { + + it('Should fail/succeed depending on the search target', async function () { + const query = { search: 'coucou' } + const paths = [ + '/api/v1/search/video-playlists/', + '/api/v1/search/video-channels/', + '/api/v1/search/videos/' + ] + + for (const path of paths) { + { + const customQuery = { ...query, searchTarget: 'hello' } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + + { + const customQuery = { ...query, searchTarget: undefined } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.OK_200 }) + } + + { + const customQuery = { ...query, searchTarget: 'local' } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.OK_200 }) + } + + { + const customQuery = { ...query, searchTarget: 'search-index' } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + + await updateSearchIndex(server, true, true) + + { + const customQuery = { ...query, searchTarget: 'search-index' } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.OK_200 }) + } + + await updateSearchIndex(server, true, false) + + { + const customQuery = { ...query, searchTarget: 'local' } + await makeGetRequest({ url: server.url, path, query: customQuery, expectedStatus: HttpStatusCode.OK_200 }) + } + + await updateSearchIndex(server, false, false) + } + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/services.ts b/packages/tests/src/api/check-params/services.ts new file mode 100644 index 000000000..0b0466d84 --- /dev/null +++ b/packages/tests/src/api/check-params/services.ts @@ -0,0 +1,207 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { + HttpStatusCode, + HttpStatusCodeType, + VideoCreateResult, + VideoPlaylistCreateResult, + VideoPlaylistPrivacy, + VideoPrivacy +} from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test services API validators', function () { + let server: PeerTubeServer + let playlistUUID: string + + let privateVideo: VideoCreateResult + let unlistedVideo: VideoCreateResult + + let privatePlaylist: VideoPlaylistCreateResult + let unlistedPlaylist: VideoPlaylistCreateResult + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(60000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + server.store.videoCreated = await server.videos.upload({ attributes: { name: 'my super name' } }) + + privateVideo = await server.videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }) + unlistedVideo = await server.videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }) + + { + const created = await server.playlists.create({ + attributes: { + displayName: 'super playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: server.store.channel.id + } + }) + + playlistUUID = created.uuid + + privatePlaylist = await server.playlists.create({ + attributes: { + displayName: 'private', + privacy: VideoPlaylistPrivacy.PRIVATE, + videoChannelId: server.store.channel.id + } + }) + + unlistedPlaylist = await server.playlists.create({ + attributes: { + displayName: 'unlisted', + privacy: VideoPlaylistPrivacy.UNLISTED, + videoChannelId: server.store.channel.id + } + }) + } + }) + + describe('Test oEmbed API validators', function () { + + it('Should fail with an invalid url', async function () { + const embedUrl = 'hello.com' + await checkParamEmbed(server, embedUrl) + }) + + it('Should fail with an invalid host', async function () { + const embedUrl = 'http://hello.com/videos/watch/' + server.store.videoCreated.uuid + await checkParamEmbed(server, embedUrl) + }) + + it('Should fail with an invalid element id', async function () { + const embedUrl = `${server.url}/videos/watch/blabla` + await checkParamEmbed(server, embedUrl) + }) + + it('Should fail with an unknown element', async function () { + const embedUrl = `${server.url}/videos/watch/88fc0165-d1f0-4a35-a51a-3b47f668689c` + await checkParamEmbed(server, embedUrl, HttpStatusCode.NOT_FOUND_404) + }) + + it('Should fail with an invalid path', async function () { + const embedUrl = `${server.url}/videos/watchs/${server.store.videoCreated.uuid}` + + await checkParamEmbed(server, embedUrl) + }) + + it('Should fail with an invalid max height', async function () { + const embedUrl = `${server.url}/videos/watch/${server.store.videoCreated.uuid}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.BAD_REQUEST_400, { maxheight: 'hello' }) + }) + + it('Should fail with an invalid max width', async function () { + const embedUrl = `${server.url}/videos/watch/${server.store.videoCreated.uuid}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.BAD_REQUEST_400, { maxwidth: 'hello' }) + }) + + it('Should fail with an invalid format', async function () { + const embedUrl = `${server.url}/videos/watch/${server.store.videoCreated.uuid}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.BAD_REQUEST_400, { format: 'blabla' }) + }) + + it('Should fail with a non supported format', async function () { + const embedUrl = `${server.url}/videos/watch/${server.store.videoCreated.uuid}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.NOT_IMPLEMENTED_501, { format: 'xml' }) + }) + + it('Should fail with a private video', async function () { + const embedUrl = `${server.url}/videos/watch/${privateVideo.uuid}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.FORBIDDEN_403) + }) + + it('Should fail with an unlisted video with the int id', async function () { + const embedUrl = `${server.url}/videos/watch/${unlistedVideo.id}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.FORBIDDEN_403) + }) + + it('Should succeed with an unlisted video using the uuid id', async function () { + for (const uuid of [ unlistedVideo.uuid, unlistedVideo.shortUUID ]) { + const embedUrl = `${server.url}/videos/watch/${uuid}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.OK_200) + } + }) + + it('Should fail with a private playlist', async function () { + const embedUrl = `${server.url}/videos/watch/playlist/${privatePlaylist.uuid}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.FORBIDDEN_403) + }) + + it('Should fail with an unlisted playlist using the int id', async function () { + const embedUrl = `${server.url}/videos/watch/playlist/${unlistedPlaylist.id}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.FORBIDDEN_403) + }) + + it('Should succeed with an unlisted playlist using the uuid id', async function () { + for (const uuid of [ unlistedPlaylist.uuid, unlistedPlaylist.shortUUID ]) { + const embedUrl = `${server.url}/videos/watch/playlist/${uuid}` + + await checkParamEmbed(server, embedUrl, HttpStatusCode.OK_200) + } + }) + + it('Should succeed with the correct params with a video', async function () { + const embedUrl = `${server.url}/videos/watch/${server.store.videoCreated.uuid}` + const query = { + format: 'json', + maxheight: 400, + maxwidth: 400 + } + + await checkParamEmbed(server, embedUrl, HttpStatusCode.OK_200, query) + }) + + it('Should succeed with the correct params with a playlist', async function () { + const embedUrl = `${server.url}/videos/watch/playlist/${playlistUUID}` + const query = { + format: 'json', + maxheight: 400, + maxwidth: 400 + } + + await checkParamEmbed(server, embedUrl, HttpStatusCode.OK_200, query) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) + +function checkParamEmbed ( + server: PeerTubeServer, + embedUrl: string, + expectedStatus: HttpStatusCodeType = HttpStatusCode.BAD_REQUEST_400, + query = {} +) { + const path = '/services/oembed' + + return makeGetRequest({ + url: server.url, + path, + query: Object.assign(query, { url: embedUrl }), + expectedStatus + }) +} diff --git a/packages/tests/src/api/check-params/transcoding.ts b/packages/tests/src/api/check-params/transcoding.ts new file mode 100644 index 000000000..50935c59e --- /dev/null +++ b/packages/tests/src/api/check-params/transcoding.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, UserRole } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test transcoding API validators', function () { + let servers: PeerTubeServer[] + + let userToken: string + let moderatorToken: string + + let remoteId: string + let validId: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + userToken = await servers[0].users.generateUserAndToken('user', UserRole.USER) + moderatorToken = await servers[0].users.generateUserAndToken('moderator', UserRole.MODERATOR) + + { + const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' }) + remoteId = uuid + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' }) + validId = uuid + } + + await waitJobs(servers) + + await servers[0].config.enableTranscoding() + }) + + it('Should not run transcoding of a unknown video', async function () { + await servers[0].videos.runTranscoding({ videoId: 404, transcodingType: 'hls', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await servers[0].videos.runTranscoding({ videoId: 404, transcodingType: 'web-video', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should not run transcoding of a remote video', async function () { + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + + await servers[0].videos.runTranscoding({ videoId: remoteId, transcodingType: 'hls', expectedStatus }) + await servers[0].videos.runTranscoding({ videoId: remoteId, transcodingType: 'web-video', expectedStatus }) + }) + + it('Should not run transcoding by a non admin user', async function () { + const expectedStatus = HttpStatusCode.FORBIDDEN_403 + + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'hls', token: userToken, expectedStatus }) + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video', token: moderatorToken, expectedStatus }) + }) + + it('Should not run transcoding without transcoding type', async function () { + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: undefined, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not run transcoding with an incorrect transcoding type', async function () { + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'toto' as any, expectedStatus }) + }) + + it('Should not run transcoding if the instance disabled it', async function () { + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + + await servers[0].config.disableTranscoding() + + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'hls', expectedStatus }) + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video', expectedStatus }) + }) + + it('Should run transcoding', async function () { + this.timeout(120_000) + + await servers[0].config.enableTranscoding() + + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'hls' }) + await waitJobs(servers) + + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video', forceTranscoding: true }) + await waitJobs(servers) + }) + + it('Should not run transcoding on a video that is already being transcoded if forceTranscoding is not set', async function () { + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video' }) + + const expectedStatus = HttpStatusCode.CONFLICT_409 + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video', expectedStatus }) + + await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video', forceTranscoding: true }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/check-params/two-factor.ts b/packages/tests/src/api/check-params/two-factor.ts new file mode 100644 index 000000000..0b1766eca --- /dev/null +++ b/packages/tests/src/api/check-params/two-factor.ts @@ -0,0 +1,294 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + TwoFactorCommand +} from '@peertube/peertube-server-commands' + +describe('Test two factor API validators', function () { + let server: PeerTubeServer + + let rootId: number + let rootPassword: string + let rootRequestToken: string + let rootOTPToken: string + + let userId: number + let userToken = '' + let userPassword: string + let userRequestToken: string + let userOTPToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + { + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + } + + { + const result = await server.users.generate('user1') + userToken = result.token + userId = result.userId + userPassword = result.password + } + + { + const { id } = await server.users.getMyInfo() + rootId = id + rootPassword = server.store.user.password + } + }) + + describe('When requesting two factor', function () { + + it('Should fail with an unknown user id', async function () { + await server.twoFactor.request({ userId: 42, currentPassword: rootPassword, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with an invalid user id', async function () { + await server.twoFactor.request({ + userId: 'invalid' as any, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to request another user two factor without the appropriate rights', async function () { + await server.twoFactor.request({ + userId: rootId, + token: userToken, + currentPassword: userPassword, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to request another user two factor with the appropriate rights', async function () { + await server.twoFactor.request({ userId, currentPassword: rootPassword }) + }) + + it('Should fail to request two factor without a password', async function () { + await server.twoFactor.request({ + userId, + token: userToken, + currentPassword: undefined, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to request two factor with an incorrect password', async function () { + await server.twoFactor.request({ + userId, + token: userToken, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to request two factor without a password when targeting a remote user with an admin account', async function () { + await server.twoFactor.request({ userId }) + }) + + it('Should fail to request two factor without a password when targeting myself with an admin account', async function () { + await server.twoFactor.request({ userId: rootId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.twoFactor.request({ userId: rootId, currentPassword: 'bad', expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed to request my two factor auth', async function () { + { + const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword }) + userRequestToken = otpRequest.requestToken + userOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate() + } + + { + const { otpRequest } = await server.twoFactor.request({ userId: rootId, currentPassword: rootPassword }) + rootRequestToken = otpRequest.requestToken + rootOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate() + } + }) + }) + + describe('When confirming two factor request', function () { + + it('Should fail with an unknown user id', async function () { + await server.twoFactor.confirmRequest({ + userId: 42, + requestToken: rootRequestToken, + otpToken: rootOTPToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with an invalid user id', async function () { + await server.twoFactor.confirmRequest({ + userId: 'invalid' as any, + requestToken: rootRequestToken, + otpToken: rootOTPToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to confirm another user two factor request without the appropriate rights', async function () { + await server.twoFactor.confirmRequest({ + userId: rootId, + token: userToken, + requestToken: rootRequestToken, + otpToken: rootOTPToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail without request token', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: undefined, + otpToken: userOTPToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an invalid request token', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: 'toto', + otpToken: userOTPToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with request token of another user', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: rootRequestToken, + otpToken: userOTPToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail without an otp token', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: userRequestToken, + otpToken: undefined, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad otp token', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: userRequestToken, + otpToken: '123456', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to confirm another user two factor request with the appropriate rights', async function () { + await server.twoFactor.confirmRequest({ + userId, + requestToken: userRequestToken, + otpToken: userOTPToken + }) + + // Reinit + await server.twoFactor.disable({ userId, currentPassword: rootPassword }) + }) + + it('Should succeed to confirm my two factor request', async function () { + await server.twoFactor.confirmRequest({ + userId, + token: userToken, + requestToken: userRequestToken, + otpToken: userOTPToken + }) + }) + + it('Should fail to confirm again two factor request', async function () { + await server.twoFactor.confirmRequest({ + userId, + token: userToken, + requestToken: userRequestToken, + otpToken: userOTPToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + describe('When disabling two factor', function () { + + it('Should fail with an unknown user id', async function () { + await server.twoFactor.disable({ + userId: 42, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with an invalid user id', async function () { + await server.twoFactor.disable({ + userId: 'invalid' as any, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to disable another user two factor without the appropriate rights', async function () { + await server.twoFactor.disable({ + userId: rootId, + token: userToken, + currentPassword: userPassword, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail to disable two factor with an incorrect password', async function () { + await server.twoFactor.disable({ + userId, + token: userToken, + currentPassword: rootPassword, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to disable two factor without a password when targeting a remote user with an admin account', async function () { + await server.twoFactor.disable({ userId }) + await server.twoFactor.requestAndConfirm({ userId }) + }) + + it('Should fail to disable two factor without a password when targeting myself with an admin account', async function () { + await server.twoFactor.disable({ userId: rootId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.twoFactor.disable({ userId: rootId, currentPassword: 'bad', expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed to disable another user two factor with the appropriate rights', async function () { + await server.twoFactor.disable({ userId, currentPassword: rootPassword }) + + await server.twoFactor.requestAndConfirm({ userId }) + }) + + it('Should succeed to update my two factor auth', async function () { + await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword }) + }) + + it('Should fail to disable again two factor', async function () { + await server.twoFactor.disable({ + userId, + token: userToken, + currentPassword: userPassword, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/upload-quota.ts b/packages/tests/src/api/check-params/upload-quota.ts new file mode 100644 index 000000000..a77792822 --- /dev/null +++ b/packages/tests/src/api/check-params/upload-quota.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { randomInt } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoImportState, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + VideosCommand, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test upload quota', function () { + let server: PeerTubeServer + let rootId: number + let command: VideosCommand + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + const user = await server.users.getMyInfo() + rootId = user.id + + await server.users.update({ userId: rootId, videoQuota: 42 }) + + command = server.videos + }) + + describe('When having a video quota', function () { + + it('Should fail with a registered user having too many videos with legacy upload', async function () { + this.timeout(120000) + + const user = { username: 'registered' + randomInt(1, 1500), password: 'password' } + await server.registrations.register(user) + const userToken = await server.login.getAccessToken(user) + + const attributes = { fixture: 'video_short2.webm' } + for (let i = 0; i < 5; i++) { + await command.upload({ token: userToken, attributes }) + } + + await command.upload({ token: userToken, attributes, expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413, mode: 'legacy' }) + }) + + it('Should fail with a registered user having too many videos with resumable upload', async function () { + this.timeout(120000) + + const user = { username: 'registered' + randomInt(1, 1500), password: 'password' } + await server.registrations.register(user) + const userToken = await server.login.getAccessToken(user) + + const attributes = { fixture: 'video_short2.webm' } + for (let i = 0; i < 5; i++) { + await command.upload({ token: userToken, attributes }) + } + + await command.upload({ token: userToken, attributes, expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413, mode: 'resumable' }) + }) + + it('Should fail to import with HTTP/Torrent/magnet', async function () { + this.timeout(120_000) + + const baseAttributes = { + channelId: server.store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + await server.imports.importVideo({ attributes: { ...baseAttributes, targetUrl: FIXTURE_URLS.goodVideo } }) + await server.imports.importVideo({ attributes: { ...baseAttributes, magnetUri: FIXTURE_URLS.magnet } }) + await server.imports.importVideo({ attributes: { ...baseAttributes, torrentfile: 'video-720p.torrent' as any } }) + + await waitJobs([ server ]) + + const { total, data: videoImports } = await server.imports.getMyVideoImports() + expect(total).to.equal(3) + + expect(videoImports).to.have.lengthOf(3) + + for (const videoImport of videoImports) { + expect(videoImport.state.id).to.equal(VideoImportState.FAILED) + expect(videoImport.error).not.to.be.undefined + expect(videoImport.error).to.contain('user video quota is exceeded') + } + }) + }) + + describe('When having a daily video quota', function () { + + it('Should fail with a user having too many videos daily', async function () { + await server.users.update({ userId: rootId, videoQuotaDaily: 42 }) + + await command.upload({ expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413, mode: 'legacy' }) + await command.upload({ expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413, mode: 'resumable' }) + }) + }) + + describe('When having an absolute and daily video quota', function () { + it('Should fail if exceeding total quota', async function () { + await server.users.update({ + userId: rootId, + videoQuota: 42, + videoQuotaDaily: 1024 * 1024 * 1024 + }) + + await command.upload({ expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413, mode: 'legacy' }) + await command.upload({ expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413, mode: 'resumable' }) + }) + + it('Should fail if exceeding daily quota', async function () { + await server.users.update({ + userId: rootId, + videoQuota: 1024 * 1024 * 1024, + videoQuotaDaily: 42 + }) + + await command.upload({ expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413, mode: 'legacy' }) + await command.upload({ expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413, mode: 'resumable' }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/user-notifications.ts b/packages/tests/src/api/check-params/user-notifications.ts new file mode 100644 index 000000000..cf20324a1 --- /dev/null +++ b/packages/tests/src/api/check-params/user-notifications.ts @@ -0,0 +1,290 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { io } from 'socket.io-client' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, UserNotificationSetting, UserNotificationSettingValue } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + makePostBodyRequest, + makePutBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test user notifications API validators', function () { + let server: PeerTubeServer + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + }) + + describe('When listing my notifications', function () { + const path = '/api/v1/users/me/notifications' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect unread parameter', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + unread: 'toto' + }, + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When marking as read my notifications', function () { + const path = '/api/v1/users/me/notifications/read' + + it('Should fail with wrong ids parameters', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { + ids: [ 'hello' ] + }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { + ids: [ ] + }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makePostBodyRequest({ + url: server.url, + path, + fields: { + ids: 5 + }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a non authenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { + ids: [ 5 ] + }, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { + ids: [ 5 ] + }, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When marking as read my notifications', function () { + const path = '/api/v1/users/me/notifications/read-all' + + it('Should fail with a non authenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When updating my notification settings', function () { + const path = '/api/v1/users/me/notification-settings' + const correctFields: UserNotificationSetting = { + newVideoFromSubscription: UserNotificationSettingValue.WEB, + newCommentOnMyVideo: UserNotificationSettingValue.WEB, + abuseAsModerator: UserNotificationSettingValue.WEB, + videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB, + blacklistOnMyVideo: UserNotificationSettingValue.WEB, + myVideoImportFinished: UserNotificationSettingValue.WEB, + myVideoPublished: UserNotificationSettingValue.WEB, + commentMention: UserNotificationSettingValue.WEB, + newFollow: UserNotificationSettingValue.WEB, + newUserRegistration: UserNotificationSettingValue.WEB, + newInstanceFollower: UserNotificationSettingValue.WEB, + autoInstanceFollowing: UserNotificationSettingValue.WEB, + abuseNewMessage: UserNotificationSettingValue.WEB, + abuseStateChange: UserNotificationSettingValue.WEB, + newPeerTubeVersion: UserNotificationSettingValue.WEB, + myVideoStudioEditionFinished: UserNotificationSettingValue.WEB, + newPluginVersion: UserNotificationSettingValue.WEB + } + + it('Should fail with missing fields', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with incorrect field values', async function () { + { + const fields = { ...correctFields, newCommentOnMyVideo: 15 } + + await makePutBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + } + + { + const fields = { ...correctFields, newCommentOnMyVideo: 'toto' } + + await makePutBodyRequest({ + url: server.url, + path, + fields, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + } + }) + + it('Should fail with a non authenticated user', async function () { + await makePutBodyRequest({ + url: server.url, + path, + fields: correctFields, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: correctFields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When connecting to my notification socket', function () { + + it('Should fail with no token', function (next) { + const socket = io(`${server.url}/user-notifications`, { reconnection: false }) + + socket.once('connect_error', function () { + socket.disconnect() + next() + }) + + socket.on('connect', () => { + socket.disconnect() + next(new Error('Connected with a missing token.')) + }) + }) + + it('Should fail with an invalid token', function (next) { + const socket = io(`${server.url}/user-notifications`, { + query: { accessToken: 'bad_access_token' }, + reconnection: false + }) + + socket.once('connect_error', function () { + socket.disconnect() + next() + }) + + socket.on('connect', () => { + socket.disconnect() + next(new Error('Connected with an invalid token.')) + }) + }) + + it('Should success with the correct token', function (next) { + const socket = io(`${server.url}/user-notifications`, { + query: { accessToken: server.accessToken }, + reconnection: false + }) + + function errorListener (err) { + next(new Error('Error in connection: ' + err)) + } + + socket.on('connect_error', errorListener) + + socket.once('connect', async () => { + socket.disconnect() + + await wait(500) + next() + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/user-subscriptions.ts b/packages/tests/src/api/check-params/user-subscriptions.ts new file mode 100644 index 000000000..e97f513a0 --- /dev/null +++ b/packages/tests/src/api/check-params/user-subscriptions.ts @@ -0,0 +1,298 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { + cleanupTests, + createSingleServer, + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { HttpStatusCode } from '@peertube/peertube-models' +import { checkBadStartPagination, checkBadCountPagination, checkBadSortPagination } from '@tests/shared/checks.js' + +describe('Test user subscriptions API validators', function () { + const path = '/api/v1/users/me/subscriptions' + let server: PeerTubeServer + let userAccessToken = '' + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const user = { + username: 'user1', + password: 'my super password' + } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + }) + + describe('When listing my subscriptions', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When listing my subscriptions videos', function () { + const path = '/api/v1/users/me/subscriptions/videos' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When adding a subscription', function () { + it('Should fail with a non authenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { uri: 'user1_channel@' + server.host }, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with bad URIs', async function () { + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { uri: 'root' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { uri: 'root@' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { uri: 'root@hello@' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + this.timeout(20000) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { uri: 'user1_channel@' + server.host }, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + + await waitJobs([ server ]) + }) + }) + + describe('When getting a subscription', function () { + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path: path + '/user1_channel@' + server.host, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with bad URIs', async function () { + await makeGetRequest({ + url: server.url, + path: path + '/root', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeGetRequest({ + url: server.url, + path: path + '/root@', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeGetRequest({ + url: server.url, + path: path + '/root@hello@', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an unknown subscription', async function () { + await makeGetRequest({ + url: server.url, + path: path + '/root1@' + server.host, + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path: path + '/user1_channel@' + server.host, + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When checking if subscriptions exist', function () { + const existPath = path + '/exist' + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path: existPath, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with bad URIs', async function () { + await makeGetRequest({ + url: server.url, + path: existPath, + query: { uris: 'toto' }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeGetRequest({ + url: server.url, + path: existPath, + query: { 'uris[]': 1 }, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path: existPath, + query: { 'uris[]': 'coucou@' + server.host }, + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When removing a subscription', function () { + it('Should fail with a non authenticated user', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/user1_channel@' + server.host, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with bad URIs', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/root', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeDeleteRequest({ + url: server.url, + path: path + '/root@', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeDeleteRequest({ + url: server.url, + path: path + '/root@hello@', + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an unknown subscription', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/root1@' + server.host, + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/user1_channel@' + server.host, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/users-admin.ts b/packages/tests/src/api/check-params/users-admin.ts new file mode 100644 index 000000000..1ad222ddc --- /dev/null +++ b/packages/tests/src/api/check-params/users-admin.ts @@ -0,0 +1,457 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' +import { omit } from '@peertube/peertube-core-utils' +import { HttpStatusCode, UserAdminFlag, UserRole } from '@peertube/peertube-models' +import { + cleanupTests, + ConfigCommand, + createSingleServer, + killallServers, + makeGetRequest, + makePostBodyRequest, + makePutBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test users admin API validators', function () { + const path = '/api/v1/users/' + let userId: number + let rootId: number + let moderatorId: number + let server: PeerTubeServer + let userToken = '' + let moderatorToken = '' + let emailPort: number + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + const emails: object[] = [] + emailPort = await MockSmtpServer.Instance.collectEmails(emails) + + { + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + } + + { + const result = await server.users.generate('user1') + userToken = result.token + userId = result.userId + } + + { + const result = await server.users.generate('moderator1', UserRole.MODERATOR) + moderatorToken = result.token + } + + { + const result = await server.users.generate('moderator2', UserRole.MODERATOR) + moderatorId = result.userId + } + }) + + describe('When listing users', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + describe('When adding a new user', function () { + const baseCorrectParams = { + username: 'user2', + email: 'test@example.com', + password: 'my super password', + videoQuota: -1, + videoQuotaDaily: -1, + role: UserRole.USER, + adminFlags: UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST + } + + it('Should fail with a too small username', async function () { + const fields = { ...baseCorrectParams, username: '' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a too long username', async function () { + const fields = { ...baseCorrectParams, username: 'super'.repeat(50) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a not lowercase username', async function () { + const fields = { ...baseCorrectParams, username: 'Toto' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with an incorrect username', async function () { + const fields = { ...baseCorrectParams, username: 'my username' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a missing email', async function () { + const fields = omit(baseCorrectParams, [ 'email' ]) + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with an invalid email', async function () { + const fields = { ...baseCorrectParams, email: 'test_example.com' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a too small password', async function () { + const fields = { ...baseCorrectParams, password: 'bla' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a too long password', async function () { + const fields = { ...baseCorrectParams, password: 'super'.repeat(61) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with empty password and no smtp configured', async function () { + const fields = { ...baseCorrectParams, password: '' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should succeed with no password on a server with smtp enabled', async function () { + this.timeout(20000) + + await killallServers([ server ]) + + await server.run(ConfigCommand.getEmailOverrideConfig(emailPort)) + + const fields = { + ...baseCorrectParams, + + password: '', + username: 'create_password', + email: 'create_password@example.com' + } + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should fail with invalid admin flags', async function () { + const fields = { ...baseCorrectParams, adminFlags: 'toto' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with an non authenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + token: 'super token', + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail if we add a user with the same username', async function () { + const fields = { ...baseCorrectParams, username: 'user1' } + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should fail if we add a user with the same email', async function () { + const fields = { ...baseCorrectParams, email: 'user1@example.com' } + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should fail with an invalid videoQuota', async function () { + const fields = { ...baseCorrectParams, videoQuota: -5 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with an invalid videoQuotaDaily', async function () { + const fields = { ...baseCorrectParams, videoQuotaDaily: -7 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail without a user role', async function () { + const fields = omit(baseCorrectParams, [ 'role' ]) + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with an invalid user role', async function () { + const fields = { ...baseCorrectParams, role: 88989 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a "peertube" username', async function () { + const fields = { ...baseCorrectParams, username: 'peertube' } + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should fail to create a moderator or an admin with a moderator', async function () { + for (const role of [ UserRole.MODERATOR, UserRole.ADMINISTRATOR ]) { + const fields = { ...baseCorrectParams, role } + + await makePostBodyRequest({ + url: server.url, + path, + token: moderatorToken, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + }) + + it('Should succeed to create a user with a moderator', async function () { + const fields = { ...baseCorrectParams, username: 'a4656', email: 'a4656@example.com', role: UserRole.USER } + + await makePostBodyRequest({ + url: server.url, + path, + token: moderatorToken, + fields, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should fail with a non admin user', async function () { + const user = { username: 'user1' } + userToken = await server.login.getAccessToken(user) + + const fields = { + username: 'user3', + email: 'test@example.com', + password: 'my super password', + videoQuota: 42000000 + } + await makePostBodyRequest({ url: server.url, path, token: userToken, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + }) + + describe('When getting a user', function () { + + it('Should fail with an non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path: path + userId, + token: 'super token', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makeGetRequest({ url: server.url, path, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ url: server.url, path: path + userId, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When updating a user', function () { + + it('Should fail with an invalid email attribute', async function () { + const fields = { + email: 'blabla' + } + + await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) + }) + + it('Should fail with an invalid emailVerified attribute', async function () { + const fields = { + emailVerified: 'yes' + } + + await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) + }) + + it('Should fail with an invalid videoQuota attribute', async function () { + const fields = { + videoQuota: -90 + } + + await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) + }) + + it('Should fail with an invalid user role attribute', async function () { + const fields = { + role: 54878 + } + + await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) + }) + + it('Should fail with a too small password', async function () { + const fields = { + currentPassword: 'password', + password: 'bla' + } + + await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) + }) + + it('Should fail with a too long password', async function () { + const fields = { + currentPassword: 'password', + password: 'super'.repeat(61) + } + + await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) + }) + + it('Should fail with an non authenticated user', async function () { + const fields = { + videoQuota: 42 + } + + await makePutBodyRequest({ + url: server.url, + path: path + userId, + token: 'super token', + fields, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail when updating root role', async function () { + const fields = { + role: UserRole.MODERATOR + } + + await makePutBodyRequest({ url: server.url, path: path + rootId, token: server.accessToken, fields }) + }) + + it('Should fail with invalid admin flags', async function () { + const fields = { adminFlags: 'toto' } + + await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail to update an admin with a moderator', async function () { + const fields = { + videoQuota: 42 + } + + await makePutBodyRequest({ + url: server.url, + path: path + moderatorId, + token: moderatorToken, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to update a user with a moderator', async function () { + const fields = { + videoQuota: 42 + } + + await makePutBodyRequest({ + url: server.url, + path: path + userId, + token: moderatorToken, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + + it('Should succeed with the correct params', async function () { + const fields = { + email: 'email@example.com', + emailVerified: true, + videoQuota: 42, + role: UserRole.USER + } + + await makePutBodyRequest({ + url: server.url, + path: path + userId, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/users-emails.ts b/packages/tests/src/api/check-params/users-emails.ts new file mode 100644 index 000000000..e382190ec --- /dev/null +++ b/packages/tests/src/api/check-params/users-emails.ts @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { HttpStatusCode, UserRole } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test users API validators', function () { + let server: PeerTubeServer + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, { + rates_limit: { + ask_send_email: { + max: 10 + } + } + }) + + await setAccessTokensToServers([ server ]) + await server.config.enableSignup(true) + + await server.users.generate('moderator2', UserRole.MODERATOR) + + await server.registrations.requestRegistration({ + username: 'request1', + registrationReason: 'tt' + }) + }) + + describe('When asking a password reset', function () { + const path = '/api/v1/users/ask-reset-password' + + it('Should fail with a missing email', async function () { + const fields = {} + + await makePostBodyRequest({ url: server.url, path, fields }) + }) + + it('Should fail with an invalid email', async function () { + const fields = { email: 'hello' } + + await makePostBodyRequest({ url: server.url, path, fields }) + }) + + it('Should success with the correct params', async function () { + const fields = { email: 'admin@example.com' } + + await makePostBodyRequest({ + url: server.url, + path, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When asking for an account verification email', function () { + const path = '/api/v1/users/ask-send-verify-email' + + it('Should fail with a missing email', async function () { + const fields = {} + + await makePostBodyRequest({ url: server.url, path, fields }) + }) + + it('Should fail with an invalid email', async function () { + const fields = { email: 'hello' } + + await makePostBodyRequest({ url: server.url, path, fields }) + }) + + it('Should succeed with the correct params', async function () { + const fields = { email: 'admin@example.com' } + + await makePostBodyRequest({ + url: server.url, + path, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When asking for a registration verification email', function () { + const path = '/api/v1/users/registrations/ask-send-verify-email' + + it('Should fail with a missing email', async function () { + const fields = {} + + await makePostBodyRequest({ url: server.url, path, fields }) + }) + + it('Should fail with an invalid email', async function () { + const fields = { email: 'hello' } + + await makePostBodyRequest({ url: server.url, path, fields }) + }) + + it('Should succeed with the correct params', async function () { + const fields = { email: 'request1@example.com' } + + await makePostBodyRequest({ + url: server.url, + path, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-blacklist.ts b/packages/tests/src/api/check-params/video-blacklist.ts new file mode 100644 index 000000000..6ec070b9b --- /dev/null +++ b/packages/tests/src/api/check-params/video-blacklist.ts @@ -0,0 +1,292 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { HttpStatusCode, VideoBlacklistType } from '@peertube/peertube-models' +import { + BlacklistCommand, + cleanupTests, + createMultipleServers, + doubleFollow, + makePostBodyRequest, + makePutBodyRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video blacklist API validators', function () { + let servers: PeerTubeServer[] + let notBlacklistedVideoId: string + let remoteVideoUUID: string + let userAccessToken1 = '' + let userAccessToken2 = '' + let command: BlacklistCommand + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await doubleFollow(servers[0], servers[1]) + + { + const username = 'user1' + const password = 'my super password' + await servers[0].users.create({ username, password }) + userAccessToken1 = await servers[0].login.getAccessToken({ username, password }) + } + + { + const username = 'user2' + const password = 'my super password' + await servers[0].users.create({ username, password }) + userAccessToken2 = await servers[0].login.getAccessToken({ username, password }) + } + + { + servers[0].store.videoCreated = await servers[0].videos.upload({ token: userAccessToken1 }) + } + + { + const { uuid } = await servers[0].videos.upload() + notBlacklistedVideoId = uuid + } + + { + const { uuid } = await servers[1].videos.upload() + remoteVideoUUID = uuid + } + + await waitJobs(servers) + + command = servers[0].blacklist + }) + + describe('When adding a video in blacklist', function () { + const basePath = '/api/v1/videos/' + + it('Should fail with nothing', async function () { + const path = basePath + servers[0].store.videoCreated + '/blacklist' + const fields = {} + await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields }) + }) + + it('Should fail with a wrong video', async function () { + const wrongPath = '/api/v1/videos/blabla/blacklist' + const fields = {} + await makePostBodyRequest({ url: servers[0].url, path: wrongPath, token: servers[0].accessToken, fields }) + }) + + it('Should fail with a non authenticated user', async function () { + const path = basePath + servers[0].store.videoCreated + '/blacklist' + const fields = {} + await makePostBodyRequest({ url: servers[0].url, path, token: 'hello', fields, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a non admin user', async function () { + const path = basePath + servers[0].store.videoCreated + '/blacklist' + const fields = {} + await makePostBodyRequest({ + url: servers[0].url, + path, + token: userAccessToken2, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid reason', async function () { + const path = basePath + servers[0].store.videoCreated.uuid + '/blacklist' + const fields = { reason: 'a'.repeat(305) } + + await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields }) + }) + + it('Should fail to unfederate a remote video', async function () { + const path = basePath + remoteVideoUUID + '/blacklist' + const fields = { unfederate: true } + + await makePostBodyRequest({ + url: servers[0].url, + path, + token: servers[0].accessToken, + fields, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should succeed with the correct params', async function () { + const path = basePath + servers[0].store.videoCreated.uuid + '/blacklist' + const fields = {} + + await makePostBodyRequest({ + url: servers[0].url, + path, + token: servers[0].accessToken, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When updating a video in blacklist', function () { + const basePath = '/api/v1/videos/' + + it('Should fail with a wrong video', async function () { + const wrongPath = '/api/v1/videos/blabla/blacklist' + const fields = {} + await makePutBodyRequest({ url: servers[0].url, path: wrongPath, token: servers[0].accessToken, fields }) + }) + + it('Should fail with a video not blacklisted', async function () { + const path = '/api/v1/videos/' + notBlacklistedVideoId + '/blacklist' + const fields = {} + await makePutBodyRequest({ + url: servers[0].url, + path, + token: servers[0].accessToken, + fields, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a non authenticated user', async function () { + const path = basePath + servers[0].store.videoCreated + '/blacklist' + const fields = {} + await makePutBodyRequest({ url: servers[0].url, path, token: 'hello', fields, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a non admin user', async function () { + const path = basePath + servers[0].store.videoCreated + '/blacklist' + const fields = {} + await makePutBodyRequest({ + url: servers[0].url, + path, + token: userAccessToken2, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid reason', async function () { + const path = basePath + servers[0].store.videoCreated.uuid + '/blacklist' + const fields = { reason: 'a'.repeat(305) } + + await makePutBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields }) + }) + + it('Should succeed with the correct params', async function () { + const path = basePath + servers[0].store.videoCreated.shortUUID + '/blacklist' + const fields = { reason: 'hello' } + + await makePutBodyRequest({ + url: servers[0].url, + path, + token: servers[0].accessToken, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When getting blacklisted video', function () { + + it('Should fail with a non authenticated user', async function () { + await servers[0].videos.get({ id: servers[0].store.videoCreated.uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with another user', async function () { + await servers[0].videos.getWithToken({ + token: userAccessToken2, + id: servers[0].store.videoCreated.uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the owner authenticated user', async function () { + const video = await servers[0].videos.getWithToken({ token: userAccessToken1, id: servers[0].store.videoCreated.uuid }) + expect(video.blacklisted).to.be.true + }) + + it('Should succeed with an admin', async function () { + const video = servers[0].store.videoCreated + + for (const id of [ video.id, video.uuid, video.shortUUID ]) { + const video = await servers[0].videos.getWithToken({ id, expectedStatus: HttpStatusCode.OK_200 }) + expect(video.blacklisted).to.be.true + } + }) + }) + + describe('When removing a video in blacklist', function () { + + it('Should fail with a non authenticated user', async function () { + await command.remove({ + token: 'fake token', + videoId: servers[0].store.videoCreated.uuid, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await command.remove({ + token: userAccessToken2, + videoId: servers[0].store.videoCreated.uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an incorrect id', async function () { + await command.remove({ videoId: 'hello', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a not blacklisted video', async function () { + // The video was not added to the blacklist so it should fail + await command.remove({ videoId: notBlacklistedVideoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct params', async function () { + await command.remove({ videoId: servers[0].store.videoCreated.uuid, expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + }) + }) + + describe('When listing videos in blacklist', function () { + const basePath = '/api/v1/videos/blacklist/' + + it('Should fail with a non authenticated user', async function () { + await servers[0].blacklist.list({ token: 'fake token', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a non admin user', async function () { + await servers[0].blacklist.list({ token: userAccessToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(servers[0].url, basePath, servers[0].accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(servers[0].url, basePath, servers[0].accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(servers[0].url, basePath, servers[0].accessToken) + }) + + it('Should fail with an invalid type', async function () { + await servers[0].blacklist.list({ type: 0 as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with the correct parameters', async function () { + await servers[0].blacklist.list({ type: VideoBlacklistType.MANUAL }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/check-params/video-captions.ts b/packages/tests/src/api/check-params/video-captions.ts new file mode 100644 index 000000000..4150b095f --- /dev/null +++ b/packages/tests/src/api/check-params/video-captions.ts @@ -0,0 +1,307 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeDeleteRequest, + makeGetRequest, + makeUploadRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test video captions API validator', function () { + const path = '/api/v1/videos/' + + let server: PeerTubeServer + let userAccessToken: string + let video: VideoCreateResult + let privateVideo: VideoCreateResult + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + video = await server.videos.upload() + privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } }) + + { + const user = { + username: 'user1', + password: 'my super password' + } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + } + }) + + describe('When adding video caption', function () { + const fields = { } + const attaches = { + captionfile: buildAbsoluteFixturePath('subtitle-good1.vtt') + } + + it('Should fail without a valid uuid', async function () { + await makeUploadRequest({ + method: 'PUT', + url: server.url, + path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions/fr', + token: server.accessToken, + fields, + attaches + }) + }) + + it('Should fail with an unknown id', async function () { + await makeUploadRequest({ + method: 'PUT', + url: server.url, + path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/fr', + token: server.accessToken, + fields, + attaches, + expectedStatus: 404 + }) + }) + + it('Should fail with a missing language in path', async function () { + const captionPath = path + video.uuid + '/captions' + await makeUploadRequest({ + method: 'PUT', + url: server.url, + path: captionPath, + token: server.accessToken, + fields, + attaches + }) + }) + + it('Should fail with an unknown language', async function () { + const captionPath = path + video.uuid + '/captions/15' + await makeUploadRequest({ + method: 'PUT', + url: server.url, + path: captionPath, + token: server.accessToken, + fields, + attaches + }) + }) + + it('Should fail without access token', async function () { + const captionPath = path + video.uuid + '/captions/fr' + await makeUploadRequest({ + method: 'PUT', + url: server.url, + path: captionPath, + fields, + attaches, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad access token', async function () { + const captionPath = path + video.uuid + '/captions/fr' + await makeUploadRequest({ + method: 'PUT', + url: server.url, + path: captionPath, + token: 'blabla', + fields, + attaches, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + // We accept any file now + // it('Should fail with an invalid captionfile extension', async function () { + // const attaches = { + // 'captionfile': buildAbsoluteFixturePath('subtitle-bad.txt') + // } + // + // const captionPath = path + video.uuid + '/captions/fr' + // await makeUploadRequest({ + // method: 'PUT', + // url: server.url, + // path: captionPath, + // token: server.accessToken, + // fields, + // attaches, + // expectedStatus: HttpStatusCode.BAD_REQUEST_400 + // }) + // }) + + // We don't check the extension yet + // it('Should fail with an invalid captionfile extension and octet-stream mime type', async function () { + // await createVideoCaption({ + // url: server.url, + // accessToken: server.accessToken, + // language: 'zh', + // videoId: video.uuid, + // fixture: 'subtitle-bad.txt', + // mimeType: 'application/octet-stream', + // expectedStatus: HttpStatusCode.BAD_REQUEST_400 + // }) + // }) + + it('Should succeed with a valid captionfile extension and octet-stream mime type', async function () { + await server.captions.add({ + language: 'zh', + videoId: video.uuid, + fixture: 'subtitle-good.srt', + mimeType: 'application/octet-stream' + }) + }) + + // We don't check the file validity yet + // it('Should fail with an invalid captionfile srt', async function () { + // const attaches = { + // 'captionfile': buildAbsoluteFixturePath('subtitle-bad.srt') + // } + // + // const captionPath = path + video.uuid + '/captions/fr' + // await makeUploadRequest({ + // method: 'PUT', + // url: server.url, + // path: captionPath, + // token: server.accessToken, + // fields, + // attaches, + // expectedStatus: HttpStatusCode.INTERNAL_SERVER_ERROR_500 + // }) + // }) + + it('Should success with the correct parameters', async function () { + const captionPath = path + video.uuid + '/captions/fr' + await makeUploadRequest({ + method: 'PUT', + url: server.url, + path: captionPath, + token: server.accessToken, + fields, + attaches, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When listing video captions', function () { + it('Should fail without a valid uuid', async function () { + await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions' }) + }) + + it('Should fail with an unknown id', async function () { + await makeGetRequest({ + url: server.url, + path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a private video without token', async function () { + await makeGetRequest({ + url: server.url, + path: path + privateVideo.shortUUID + '/captions', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with another user token', async function () { + await makeGetRequest({ + url: server.url, + token: userAccessToken, + path: path + privateVideo.shortUUID + '/captions', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path: path + video.shortUUID + '/captions', expectedStatus: HttpStatusCode.OK_200 }) + + await makeGetRequest({ + url: server.url, + path: path + privateVideo.shortUUID + '/captions', + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When deleting video caption', function () { + it('Should fail without a valid uuid', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions/fr', + token: server.accessToken + }) + }) + + it('Should fail with an unknown id', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/fr', + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with an invalid language', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/16', + token: server.accessToken + }) + }) + + it('Should fail with a missing language', async function () { + const captionPath = path + video.shortUUID + '/captions' + await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken }) + }) + + it('Should fail with an unknown language', async function () { + const captionPath = path + video.shortUUID + '/captions/15' + await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken }) + }) + + it('Should fail without access token', async function () { + const captionPath = path + video.shortUUID + '/captions/fr' + await makeDeleteRequest({ url: server.url, path: captionPath, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a bad access token', async function () { + const captionPath = path + video.shortUUID + '/captions/fr' + await makeDeleteRequest({ url: server.url, path: captionPath, token: 'coucou', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with another user', async function () { + const captionPath = path + video.shortUUID + '/captions/fr' + await makeDeleteRequest({ + url: server.url, + path: captionPath, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should success with the correct parameters', async function () { + const captionPath = path + video.shortUUID + '/captions/fr' + await makeDeleteRequest({ + url: server.url, + path: captionPath, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-channel-syncs.ts b/packages/tests/src/api/check-params/video-channel-syncs.ts new file mode 100644 index 000000000..d95f3319a --- /dev/null +++ b/packages/tests/src/api/check-params/video-channel-syncs.ts @@ -0,0 +1,319 @@ +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { HttpStatusCode, VideoChannelSyncCreate } from '@peertube/peertube-models' +import { + ChannelSyncsCommand, + createSingleServer, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test video channel sync API validator', () => { + const path = '/api/v1/video-channel-syncs' + let server: PeerTubeServer + let command: ChannelSyncsCommand + let rootChannelId: number + let rootChannelSyncId: number + const userInfo = { + accessToken: '', + username: 'user1', + id: -1, + channelId: -1, + syncId: -1 + } + + async function withChannelSyncDisabled (callback: () => Promise): Promise { + try { + await server.config.disableChannelSync() + await callback() + } finally { + await server.config.enableChannelSync() + } + } + + async function withMaxSyncsPerUser (maxSync: number, callback: () => Promise): Promise { + const origConfig = await server.config.getCustomConfig() + + await server.config.updateExistingSubConfig({ + newConfig: { + import: { + videoChannelSynchronization: { + maxPerUser: maxSync + } + } + } + }) + + try { + await callback() + } finally { + await server.config.updateCustomConfig({ newCustomConfig: origConfig }) + } + } + + before(async function () { + this.timeout(30_000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + command = server.channelSyncs + + rootChannelId = server.store.channel.id + + { + userInfo.accessToken = await server.users.generateUserAndToken(userInfo.username) + + const { videoChannels, id: userId } = await server.users.getMyInfo({ token: userInfo.accessToken }) + userInfo.id = userId + userInfo.channelId = videoChannels[0].id + } + + await server.config.enableChannelSync() + }) + + describe('When creating a sync', function () { + let baseCorrectParams: VideoChannelSyncCreate + + before(function () { + baseCorrectParams = { + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelId: rootChannelId + } + }) + + it('Should fail when sync is disabled', async function () { + await withChannelSyncDisabled(async () => { + await command.create({ + token: server.accessToken, + attributes: baseCorrectParams, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + it('Should fail with nothing', async function () { + const fields = {} + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with no authentication', async function () { + await command.create({ + token: null, + attributes: baseCorrectParams, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail without a target url', async function () { + const attributes: VideoChannelSyncCreate = { + ...baseCorrectParams, + externalChannelUrl: null + } + await command.create({ + token: server.accessToken, + attributes, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail without a channelId', async function () { + const attributes: VideoChannelSyncCreate = { + ...baseCorrectParams, + videoChannelId: null + } + await command.create({ + token: server.accessToken, + attributes, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a channelId refering nothing', async function () { + const attributes: VideoChannelSyncCreate = { + ...baseCorrectParams, + videoChannelId: 42 + } + await command.create({ + token: server.accessToken, + attributes, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail to create a sync when the user does not own the channel', async function () { + await command.create({ + token: userInfo.accessToken, + attributes: baseCorrectParams, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed to create a sync with root and for another user\'s channel', async function () { + const { videoChannelSync } = await command.create({ + token: server.accessToken, + attributes: { + ...baseCorrectParams, + videoChannelId: userInfo.channelId + }, + expectedStatus: HttpStatusCode.OK_200 + }) + userInfo.syncId = videoChannelSync.id + }) + + it('Should succeed with the correct parameters', async function () { + const { videoChannelSync } = await command.create({ + token: server.accessToken, + attributes: baseCorrectParams, + expectedStatus: HttpStatusCode.OK_200 + }) + rootChannelSyncId = videoChannelSync.id + }) + + it('Should fail when the user exceeds allowed number of synchronizations', async function () { + await withMaxSyncsPerUser(1, async () => { + await command.create({ + token: server.accessToken, + attributes: { + ...baseCorrectParams, + videoChannelId: userInfo.channelId + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + }) + + describe('When listing my channel syncs', function () { + const myPath = '/api/v1/accounts/root/video-channel-syncs' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, myPath, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, myPath, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, myPath, server.accessToken) + }) + + it('Should succeed with the correct parameters', async function () { + await command.listByAccount({ + accountName: 'root', + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should fail with no authentication', async function () { + await command.listByAccount({ + accountName: 'root', + token: null, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail when a simple user lists another user\'s synchronizations', async function () { + await command.listByAccount({ + accountName: 'root', + token: userInfo.accessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed when root lists another user\'s synchronizations', async function () { + await command.listByAccount({ + accountName: userInfo.username, + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should succeed even with synchronization disabled', async function () { + await withChannelSyncDisabled(async function () { + await command.listByAccount({ + accountName: 'root', + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + }) + + describe('When triggering deletion', function () { + it('should fail with no authentication', async function () { + await command.delete({ + channelSyncId: userInfo.syncId, + token: null, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail when channelSyncId does not refer to any sync', async function () { + await command.delete({ + channelSyncId: 42, + token: server.accessToken, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail when sync is not owned by the user', async function () { + await command.delete({ + channelSyncId: rootChannelSyncId, + token: userInfo.accessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed when root delete a sync they do not own', async function () { + await command.delete({ + channelSyncId: userInfo.syncId, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + + it('should succeed when user delete a sync they own', async function () { + const { videoChannelSync } = await command.create({ + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelId: userInfo.channelId + }, + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200 + }) + + await command.delete({ + channelSyncId: videoChannelSync.id, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + + it('Should succeed even when synchronization is disabled', async function () { + await withChannelSyncDisabled(async function () { + await command.delete({ + channelSyncId: rootChannelSyncId, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + }) + + after(async function () { + await server?.kill() + }) +}) diff --git a/packages/tests/src/api/check-params/video-channels.ts b/packages/tests/src/api/check-params/video-channels.ts new file mode 100644 index 000000000..84b962b19 --- /dev/null +++ b/packages/tests/src/api/check-params/video-channels.ts @@ -0,0 +1,379 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { omit } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoChannelUpdate } from '@peertube/peertube-models' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + ChannelsCommand, + cleanupTests, + createSingleServer, + makeGetRequest, + makePostBodyRequest, + makePutBodyRequest, + makeUploadRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test video channels API validator', function () { + const videoChannelPath = '/api/v1/video-channels' + let server: PeerTubeServer + const userInfo = { + accessToken: '', + channelName: 'fake_channel', + id: -1, + videoQuota: -1, + videoQuotaDaily: -1 + } + let command: ChannelsCommand + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const userCreds = { + username: 'fake', + password: 'fake_password' + } + + { + const user = await server.users.create({ username: userCreds.username, password: userCreds.password }) + userInfo.id = user.id + userInfo.accessToken = await server.login.getAccessToken(userCreds) + } + + command = server.channels + }) + + describe('When listing a video channels', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, videoChannelPath, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, videoChannelPath, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, videoChannelPath, server.accessToken) + }) + }) + + describe('When listing account video channels', function () { + const accountChannelPath = '/api/v1/accounts/fake/video-channels' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, accountChannelPath, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, accountChannelPath, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, accountChannelPath, server.accessToken) + }) + + it('Should fail with a unknown account', async function () { + await server.channels.listByAccount({ accountName: 'unknown', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path: accountChannelPath, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When adding a video channel', function () { + const baseCorrectParams = { + name: 'super_channel', + displayName: 'hello', + description: 'super description', + support: 'super support text' + } + + it('Should fail with a non authenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path: videoChannelPath, + token: 'none', + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with nothing', async function () { + const fields = {} + await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields }) + }) + + it('Should fail without a name', async function () { + const fields = omit(baseCorrectParams, [ 'name' ]) + await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields }) + }) + + it('Should fail with a bad name', async function () { + const fields = { ...baseCorrectParams, name: 'super name' } + await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields }) + }) + + it('Should fail without a name', async function () { + const fields = omit(baseCorrectParams, [ 'displayName' ]) + await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields }) + }) + + it('Should fail with a long name', async function () { + const fields = { ...baseCorrectParams, displayName: 'super'.repeat(25) } + await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields }) + }) + + it('Should fail with a long description', async function () { + const fields = { ...baseCorrectParams, description: 'super'.repeat(201) } + await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields }) + }) + + it('Should fail with a long support text', async function () { + const fields = { ...baseCorrectParams, support: 'super'.repeat(201) } + await makePostBodyRequest({ url: server.url, path: videoChannelPath, token: server.accessToken, fields }) + }) + + it('Should succeed with the correct parameters', async function () { + await makePostBodyRequest({ + url: server.url, + path: videoChannelPath, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should fail when adding a channel with the same username', async function () { + await makePostBodyRequest({ + url: server.url, + path: videoChannelPath, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + }) + + describe('When updating a video channel', function () { + const baseCorrectParams: VideoChannelUpdate = { + displayName: 'hello', + description: 'super description', + support: 'toto', + bulkVideosSupportUpdate: false + } + let path: string + + before(async function () { + path = videoChannelPath + '/super_channel' + }) + + it('Should fail with a non authenticated user', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: 'hi', + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with another authenticated user', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: userInfo.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a long name', async function () { + const fields = { ...baseCorrectParams, displayName: 'super'.repeat(25) } + await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a long description', async function () { + const fields = { ...baseCorrectParams, description: 'super'.repeat(201) } + await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a long support text', async function () { + const fields = { ...baseCorrectParams, support: 'super'.repeat(201) } + await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad bulkVideosSupportUpdate field', async function () { + const fields = { ...baseCorrectParams, bulkVideosSupportUpdate: 'super' } + await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should succeed with the correct parameters', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When updating video channel avatars/banners', function () { + const types = [ 'avatar', 'banner' ] + let path: string + + before(async function () { + path = videoChannelPath + '/super_channel' + }) + + it('Should fail with an incorrect input file', async function () { + for (const type of types) { + const fields = {} + const attaches = { + [type + 'file']: buildAbsoluteFixturePath('video_short.mp4') + } + + await makeUploadRequest({ url: server.url, path: `${path}/${type}/pick`, token: server.accessToken, fields, attaches }) + } + }) + + it('Should fail with a big file', async function () { + for (const type of types) { + const fields = {} + const attaches = { + [type + 'file']: buildAbsoluteFixturePath('avatar-big.png') + } + await makeUploadRequest({ url: server.url, path: `${path}/${type}/pick`, token: server.accessToken, fields, attaches }) + } + }) + + it('Should fail with an unauthenticated user', async function () { + for (const type of types) { + const fields = {} + const attaches = { + [type + 'file']: buildAbsoluteFixturePath('avatar.png') + } + await makeUploadRequest({ + url: server.url, + path: `${path}/${type}/pick`, + fields, + attaches, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + } + }) + + it('Should succeed with the correct params', async function () { + for (const type of types) { + const fields = {} + const attaches = { + [type + 'file']: buildAbsoluteFixturePath('avatar.png') + } + await makeUploadRequest({ + url: server.url, + path: `${path}/${type}/pick`, + token: server.accessToken, + fields, + attaches, + expectedStatus: HttpStatusCode.OK_200 + }) + } + }) + }) + + describe('When getting a video channel', function () { + it('Should return the list of the video channels with nothing', async function () { + const res = await makeGetRequest({ + url: server.url, + path: videoChannelPath, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.data).to.be.an('array') + }) + + it('Should return 404 with an incorrect video channel', async function () { + await makeGetRequest({ + url: server.url, + path: videoChannelPath + '/super_channel2', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path: videoChannelPath + '/super_channel', + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When getting channel followers', function () { + const path = '/api/v1/video-channels/super_channel/followers' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a unauthenticated user', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a another user', async function () { + await makeGetRequest({ url: server.url, path, token: userInfo.accessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When deleting a video channel', function () { + it('Should fail with a non authenticated user', async function () { + await command.delete({ token: 'coucou', channelName: 'super_channel', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with another authenticated user', async function () { + await command.delete({ token: userInfo.accessToken, channelName: 'super_channel', expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with an unknown video channel id', async function () { + await command.delete({ channelName: 'super_channel2', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the correct parameters', async function () { + await command.delete({ channelName: 'super_channel' }) + }) + + it('Should fail to delete the last user video channel', async function () { + await command.delete({ channelName: 'root_channel', expectedStatus: HttpStatusCode.CONFLICT_409 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-comments.ts b/packages/tests/src/api/check-params/video-comments.ts new file mode 100644 index 000000000..177361606 --- /dev/null +++ b/packages/tests/src/api/check-params/video-comments.ts @@ -0,0 +1,484 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test video comments API validator', function () { + let pathThread: string + let pathComment: string + + let server: PeerTubeServer + + let video: VideoCreateResult + + let userAccessToken: string + let userAccessToken2: string + + let commentId: number + let privateCommentId: number + let privateVideo: VideoCreateResult + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + { + video = await server.videos.upload({ attributes: {} }) + pathThread = '/api/v1/videos/' + video.uuid + '/comment-threads' + } + + { + privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } }) + } + + { + const created = await server.comments.createThread({ videoId: video.uuid, text: 'coucou' }) + commentId = created.id + pathComment = '/api/v1/videos/' + video.uuid + '/comments/' + commentId + } + + { + const created = await server.comments.createThread({ videoId: privateVideo.uuid, text: 'coucou' }) + privateCommentId = created.id + } + + { + const user = { username: 'user1', password: 'my super password' } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + } + + { + const user = { username: 'user2', password: 'my super password' } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken2 = await server.login.getAccessToken(user) + } + }) + + describe('When listing video comment threads', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, pathThread, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, pathThread, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, pathThread, server.accessToken) + }) + + it('Should fail with an incorrect video', async function () { + await makeGetRequest({ + url: server.url, + path: '/api/v1/videos/ba708d62-e3d7-45d9-9d73-41b9097cc02d/comment-threads', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a private video without token', async function () { + await makeGetRequest({ + url: server.url, + path: '/api/v1/videos/' + privateVideo.shortUUID + '/comment-threads', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with another user token', async function () { + await makeGetRequest({ + url: server.url, + token: userAccessToken, + path: '/api/v1/videos/' + privateVideo.shortUUID + '/comment-threads', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path: '/api/v1/videos/' + privateVideo.shortUUID + '/comment-threads', + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When listing comments of a thread', function () { + it('Should fail with an incorrect video', async function () { + await makeGetRequest({ + url: server.url, + path: '/api/v1/videos/ba708d62-e3d7-45d9-9d73-41b9097cc02d/comment-threads/' + commentId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with an incorrect thread id', async function () { + await makeGetRequest({ + url: server.url, + path: '/api/v1/videos/' + video.shortUUID + '/comment-threads/156', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a private video without token', async function () { + await makeGetRequest({ + url: server.url, + path: '/api/v1/videos/' + privateVideo.shortUUID + '/comment-threads/' + privateCommentId, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with another user token', async function () { + await makeGetRequest({ + url: server.url, + token: userAccessToken, + path: '/api/v1/videos/' + privateVideo.shortUUID + '/comment-threads/' + privateCommentId, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should success with the correct params', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path: '/api/v1/videos/' + privateVideo.shortUUID + '/comment-threads/' + privateCommentId, + expectedStatus: HttpStatusCode.OK_200 + }) + + await makeGetRequest({ + url: server.url, + path: '/api/v1/videos/' + video.shortUUID + '/comment-threads/' + commentId, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When adding a video thread', function () { + + it('Should fail with a non authenticated user', async function () { + const fields = { + text: 'text' + } + await makePostBodyRequest({ + url: server.url, + path: pathThread, + token: 'none', + fields, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with nothing', async function () { + const fields = {} + await makePostBodyRequest({ url: server.url, path: pathThread, token: server.accessToken, fields }) + }) + + it('Should fail with a short comment', async function () { + const fields = { + text: '' + } + await makePostBodyRequest({ url: server.url, path: pathThread, token: server.accessToken, fields }) + }) + + it('Should fail with a long comment', async function () { + const fields = { + text: 'h'.repeat(10001) + } + await makePostBodyRequest({ url: server.url, path: pathThread, token: server.accessToken, fields }) + }) + + it('Should fail with an incorrect video', async function () { + const path = '/api/v1/videos/ba708d62-e3d7-45d9-9d73-41b9097cc02d/comment-threads' + const fields = { text: 'super comment' } + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a private video of another user', async function () { + const fields = { text: 'super comment' } + + await makePostBodyRequest({ + url: server.url, + path: '/api/v1/videos/' + privateVideo.shortUUID + '/comment-threads', + token: userAccessToken, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct parameters', async function () { + const fields = { text: 'super comment' } + + await makePostBodyRequest({ + url: server.url, + path: pathThread, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When adding a comment to a thread', function () { + + it('Should fail with a non authenticated user', async function () { + const fields = { + text: 'text' + } + await makePostBodyRequest({ + url: server.url, + path: pathComment, + token: 'none', + fields, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with nothing', async function () { + const fields = {} + await makePostBodyRequest({ url: server.url, path: pathComment, token: server.accessToken, fields }) + }) + + it('Should fail with a short comment', async function () { + const fields = { + text: '' + } + await makePostBodyRequest({ url: server.url, path: pathComment, token: server.accessToken, fields }) + }) + + it('Should fail with a long comment', async function () { + const fields = { + text: 'h'.repeat(10001) + } + await makePostBodyRequest({ url: server.url, path: pathComment, token: server.accessToken, fields }) + }) + + it('Should fail with an incorrect video', async function () { + const path = '/api/v1/videos/ba708d62-e3d7-45d9-9d73-41b9097cc02d/comments/' + commentId + const fields = { + text: 'super comment' + } + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a private video of another user', async function () { + const fields = { text: 'super comment' } + + await makePostBodyRequest({ + url: server.url, + path: '/api/v1/videos/' + privateVideo.uuid + '/comments/' + privateCommentId, + token: userAccessToken, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an incorrect comment', async function () { + const path = '/api/v1/videos/' + video.uuid + '/comments/124' + const fields = { + text: 'super comment' + } + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct parameters', async function () { + const fields = { + text: 'super comment' + } + await makePostBodyRequest({ + url: server.url, + path: pathComment, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When removing video comments', function () { + it('Should fail with a non authenticated user', async function () { + await makeDeleteRequest({ url: server.url, path: pathComment, token: 'none', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with another user', async function () { + await makeDeleteRequest({ + url: server.url, + path: pathComment, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an incorrect video', async function () { + const path = '/api/v1/videos/ba708d62-e3d7-45d9-9d73-41b9097cc02d/comments/' + commentId + await makeDeleteRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with an incorrect comment', async function () { + const path = '/api/v1/videos/' + video.uuid + '/comments/124' + await makeDeleteRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with the same user', async function () { + let commentToDelete: number + + { + const created = await server.comments.createThread({ videoId: video.uuid, token: userAccessToken, text: 'hello' }) + commentToDelete = created.id + } + + const path = '/api/v1/videos/' + video.uuid + '/comments/' + commentToDelete + + await makeDeleteRequest({ url: server.url, path, token: userAccessToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeDeleteRequest({ url: server.url, path, token: userAccessToken, expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + }) + + it('Should succeed with the owner of the video', async function () { + let commentToDelete: number + let anotherVideoUUID: string + + { + const { uuid } = await server.videos.upload({ token: userAccessToken, attributes: { name: 'video' } }) + anotherVideoUUID = uuid + } + + { + const created = await server.comments.createThread({ videoId: anotherVideoUUID, text: 'hello' }) + commentToDelete = created.id + } + + const path = '/api/v1/videos/' + anotherVideoUUID + '/comments/' + commentToDelete + + await makeDeleteRequest({ url: server.url, path, token: userAccessToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeDeleteRequest({ url: server.url, path, token: userAccessToken, expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeDeleteRequest({ + url: server.url, + path: pathComment, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When a video has comments disabled', function () { + before(async function () { + video = await server.videos.upload({ attributes: { commentsEnabled: false } }) + pathThread = '/api/v1/videos/' + video.uuid + '/comment-threads' + }) + + it('Should return an empty thread list', async function () { + const res = await makeGetRequest({ + url: server.url, + path: pathThread, + expectedStatus: HttpStatusCode.OK_200 + }) + expect(res.body.total).to.equal(0) + expect(res.body.data).to.have.lengthOf(0) + }) + + it('Should return an thread comments list') + + it('Should return conflict on thread add', async function () { + const fields = { + text: 'super comment' + } + await makePostBodyRequest({ + url: server.url, + path: pathThread, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should return conflict on comment thread add') + }) + + describe('When listing admin comments threads', function () { + const path = '/api/v1/videos/comments' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + path, + token: server.accessToken, + query: { + isLocal: false, + search: 'toto', + searchAccount: 'toto', + searchVideo: 'toto' + }, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-files.ts b/packages/tests/src/api/check-params/video-files.ts new file mode 100644 index 000000000..b5819ff19 --- /dev/null +++ b/packages/tests/src/api/check-params/video-files.ts @@ -0,0 +1,195 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { getAllFiles } from '@peertube/peertube-core-utils' +import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeRawRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test videos files', function () { + let servers: PeerTubeServer[] + + let userToken: string + let moderatorToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(300_000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + userToken = await servers[0].users.generateUserAndToken('user', UserRole.USER) + moderatorToken = await servers[0].users.generateUserAndToken('moderator', UserRole.MODERATOR) + }) + + describe('Getting metadata', function () { + let video: VideoDetails + + before(async function () { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + video = await servers[0].videos.getWithToken({ id: uuid }) + }) + + it('Should not get metadata of private video without token', async function () { + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.metadataUrl, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + } + }) + + it('Should not get metadata of private video without the appropriate token', async function () { + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.metadataUrl, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + }) + + it('Should get metadata of private video with the appropriate token', async function () { + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.metadataUrl, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + }) + + describe('Deleting files', function () { + let webVideoId: string + let hlsId: string + let remoteId: string + + let validId1: string + let validId2: string + + let hlsFileId: number + let webVideoFileId: number + + let remoteHLSFileId: number + let remoteWebVideoFileId: number + + before(async function () { + this.timeout(300_000) + + { + const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' }) + await waitJobs(servers) + + const video = await servers[1].videos.get({ id: uuid }) + remoteId = video.uuid + remoteHLSFileId = video.streamingPlaylists[0].files[0].id + remoteWebVideoFileId = video.files[0].id + } + + { + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' }) + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: uuid }) + validId1 = video.uuid + hlsFileId = video.streamingPlaylists[0].files[0].id + webVideoFileId = video.files[0].id + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' }) + validId2 = uuid + } + } + + await waitJobs(servers) + + { + await servers[0].config.enableTranscoding({ hls: true, webVideo: false }) + const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' }) + hlsId = uuid + } + + await waitJobs(servers) + + { + await servers[0].config.enableTranscoding({ webVideo: true, hls: false }) + const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' }) + webVideoId = uuid + } + + await waitJobs(servers) + }) + + it('Should not delete files of a unknown video', async function () { + const expectedStatus = HttpStatusCode.NOT_FOUND_404 + + await servers[0].videos.removeHLSPlaylist({ videoId: 404, expectedStatus }) + await servers[0].videos.removeAllWebVideoFiles({ videoId: 404, expectedStatus }) + + await servers[0].videos.removeHLSFile({ videoId: 404, fileId: hlsFileId, expectedStatus }) + await servers[0].videos.removeWebVideoFile({ videoId: 404, fileId: webVideoFileId, expectedStatus }) + }) + + it('Should not delete unknown files', async function () { + const expectedStatus = HttpStatusCode.NOT_FOUND_404 + + await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: webVideoFileId, expectedStatus }) + await servers[0].videos.removeWebVideoFile({ videoId: validId1, fileId: hlsFileId, expectedStatus }) + }) + + it('Should not delete files of a remote video', async function () { + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + + await servers[0].videos.removeHLSPlaylist({ videoId: remoteId, expectedStatus }) + await servers[0].videos.removeAllWebVideoFiles({ videoId: remoteId, expectedStatus }) + + await servers[0].videos.removeHLSFile({ videoId: remoteId, fileId: remoteHLSFileId, expectedStatus }) + await servers[0].videos.removeWebVideoFile({ videoId: remoteId, fileId: remoteWebVideoFileId, expectedStatus }) + }) + + it('Should not delete files by a non admin user', async function () { + const expectedStatus = HttpStatusCode.FORBIDDEN_403 + + await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: userToken, expectedStatus }) + await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: moderatorToken, expectedStatus }) + + await servers[0].videos.removeAllWebVideoFiles({ videoId: validId1, token: userToken, expectedStatus }) + await servers[0].videos.removeAllWebVideoFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) + + await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: userToken, expectedStatus }) + await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: moderatorToken, expectedStatus }) + + await servers[0].videos.removeWebVideoFile({ videoId: validId1, fileId: webVideoFileId, token: userToken, expectedStatus }) + await servers[0].videos.removeWebVideoFile({ videoId: validId1, fileId: webVideoFileId, token: moderatorToken, expectedStatus }) + }) + + it('Should not delete files if the files are not available', async function () { + await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await servers[0].videos.removeAllWebVideoFiles({ videoId: webVideoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should not delete files if no both versions are available', async function () { + await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await servers[0].videos.removeAllWebVideoFiles({ videoId: webVideoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should delete files if both versions are available', async function () { + await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId }) + await servers[0].videos.removeWebVideoFile({ videoId: validId1, fileId: webVideoFileId }) + + await servers[0].videos.removeHLSPlaylist({ videoId: validId1 }) + await servers[0].videos.removeAllWebVideoFiles({ videoId: validId2 }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/check-params/video-imports.ts b/packages/tests/src/api/check-params/video-imports.ts new file mode 100644 index 000000000..e078cedd6 --- /dev/null +++ b/packages/tests/src/api/check-params/video-imports.ts @@ -0,0 +1,433 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { omit } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + makePostBodyRequest, + makeUploadRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video imports API validator', function () { + const path = '/api/v1/videos/imports' + let server: PeerTubeServer + let userAccessToken = '' + let channelId: number + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + const username = 'user1' + const password = 'my super password' + await server.users.create({ username, password }) + userAccessToken = await server.login.getAccessToken({ username, password }) + + { + const { videoChannels } = await server.users.getMyInfo() + channelId = videoChannels[0].id + } + }) + + describe('When listing my video imports', function () { + const myPath = '/api/v1/users/me/videos/imports' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, myPath, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, myPath, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, myPath, server.accessToken) + }) + + it('Should fail with a bad videoChannelSyncId param', async function () { + await makeGetRequest({ + url: server.url, + path: myPath, + query: { videoChannelSyncId: 'toto' }, + token: server.accessToken + }) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path: myPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken }) + }) + }) + + describe('When adding a video import', function () { + let baseCorrectParams + + before(function () { + baseCorrectParams = { + targetUrl: FIXTURE_URLS.goodVideo, + name: 'my super name', + category: 5, + licence: 1, + language: 'pt', + nsfw: false, + commentsEnabled: true, + downloadEnabled: true, + waitTranscoding: true, + description: 'my super description', + support: 'my super support text', + tags: [ 'tag1', 'tag2' ], + privacy: VideoPrivacy.PUBLIC, + channelId + } + }) + + it('Should fail with nothing', async function () { + const fields = {} + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail without a target url', async function () { + const fields = omit(baseCorrectParams, [ 'targetUrl' ]) + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad target url', async function () { + const fields = { ...baseCorrectParams, targetUrl: 'htt://hello' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with localhost', async function () { + const fields = { ...baseCorrectParams, targetUrl: 'http://localhost:8000' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a private IP target urls', async function () { + const targetUrls = [ + 'http://127.0.0.1:8000', + 'http://127.0.0.1', + 'http://127.0.0.1/hello', + 'https://192.168.1.42', + 'http://192.168.1.42', + 'http://127.0.0.1.cpy.re' + ] + + for (const targetUrl of targetUrls) { + const fields = { ...baseCorrectParams, targetUrl } + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + }) + + it('Should fail with a long name', async function () { + const fields = { ...baseCorrectParams, name: 'super'.repeat(65) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad category', async function () { + const fields = { ...baseCorrectParams, category: 125 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad licence', async function () { + const fields = { ...baseCorrectParams, licence: 125 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad language', async function () { + const fields = { ...baseCorrectParams, language: 'a'.repeat(15) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a long description', async function () { + const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a long support text', async function () { + const fields = { ...baseCorrectParams, support: 'super'.repeat(201) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail without a channel', async function () { + const fields = omit(baseCorrectParams, [ 'channelId' ]) + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a bad channel', async function () { + const fields = { ...baseCorrectParams, channelId: 545454 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with another user channel', async function () { + const user = { + username: 'fake', + password: 'fake_password' + } + await server.users.create({ username: user.username, password: user.password }) + + const accessTokenUser = await server.login.getAccessToken(user) + const { videoChannels } = await server.users.getMyInfo({ token: accessTokenUser }) + const customChannelId = videoChannels[0].id + + const fields = { ...baseCorrectParams, channelId: customChannelId } + + await makePostBodyRequest({ url: server.url, path, token: userAccessToken, fields }) + }) + + it('Should fail with too many tags', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a tag length too low', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 't' ] } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a tag length too big', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with an incorrect thumbnail file', async function () { + const fields = baseCorrectParams + const attaches = { + thumbnailfile: buildAbsoluteFixturePath('video_short.mp4') + } + + await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) + }) + + it('Should fail with a big thumbnail file', async function () { + const fields = baseCorrectParams + const attaches = { + thumbnailfile: buildAbsoluteFixturePath('custom-preview-big.png') + } + + await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) + }) + + it('Should fail with an incorrect preview file', async function () { + const fields = baseCorrectParams + const attaches = { + previewfile: buildAbsoluteFixturePath('video_short.mp4') + } + + await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) + }) + + it('Should fail with a big preview file', async function () { + const fields = baseCorrectParams + const attaches = { + previewfile: buildAbsoluteFixturePath('custom-preview-big.png') + } + + await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) + }) + + it('Should fail with an invalid torrent file', async function () { + const fields = omit(baseCorrectParams, [ 'targetUrl' ]) + const attaches = { + torrentfile: buildAbsoluteFixturePath('avatar-big.png') + } + + await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) + }) + + it('Should fail with an invalid magnet URI', async function () { + let fields = omit(baseCorrectParams, [ 'targetUrl' ]) + fields = { ...fields, magnetUri: 'blabla' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should succeed with the correct parameters', async function () { + this.timeout(120000) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should forbid to import http videos', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + import: { + videos: { + http: { + enabled: false + }, + torrent: { + enabled: true + } + } + } + } + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: baseCorrectParams, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should forbid to import torrent videos', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + import: { + videos: { + http: { + enabled: true + }, + torrent: { + enabled: false + } + } + } + } + }) + + let fields = omit(baseCorrectParams, [ 'targetUrl' ]) + fields = { ...fields, magnetUri: FIXTURE_URLS.magnet } + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + + fields = omit(fields, [ 'magnetUri' ]) + const attaches = { + torrentfile: buildAbsoluteFixturePath('video-720p.torrent') + } + + await makeUploadRequest({ + url: server.url, + path, + token: server.accessToken, + fields, + attaches, + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + }) + + describe('Deleting/cancelling a video import', function () { + let importId: number + + async function importVideo () { + const attributes = { channelId: server.store.channel.id, targetUrl: FIXTURE_URLS.goodVideo } + const res = await server.imports.importVideo({ attributes }) + + return res.id + } + + before(async function () { + importId = await importVideo() + }) + + it('Should fail with an invalid import id', async function () { + await server.imports.cancel({ importId: 'artyom' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.imports.delete({ importId: 'artyom' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown import id', async function () { + await server.imports.cancel({ importId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await server.imports.delete({ importId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail without token', async function () { + await server.imports.cancel({ importId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await server.imports.delete({ importId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with another user token', async function () { + await server.imports.cancel({ importId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await server.imports.delete({ importId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail to cancel non pending import', async function () { + this.timeout(60000) + + await waitJobs([ server ]) + + await server.imports.cancel({ importId, expectedStatus: HttpStatusCode.CONFLICT_409 }) + }) + + it('Should succeed to delete an import', async function () { + await server.imports.delete({ importId }) + }) + + it('Should fail to delete a pending import', async function () { + await server.jobs.pauseJobQueue() + + importId = await importVideo() + + await server.imports.delete({ importId, expectedStatus: HttpStatusCode.CONFLICT_409 }) + }) + + it('Should succeed to cancel an import', async function () { + importId = await importVideo() + + await server.imports.cancel({ importId }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-passwords.ts b/packages/tests/src/api/check-params/video-passwords.ts new file mode 100644 index 000000000..3f57ebe74 --- /dev/null +++ b/packages/tests/src/api/check-params/video-passwords.ts @@ -0,0 +1,604 @@ +import { expect } from 'chai' +import { + HttpStatusCode, + HttpStatusCodeType, + PeerTubeProblemDocument, + ServerErrorCode, + VideoCreateResult, + VideoPrivacy +} from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createSingleServer, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { checkUploadVideoParam } from '@tests/shared/videos.js' + +describe('Test video passwords validator', function () { + let path: string + let server: PeerTubeServer + let userAccessToken = '' + let video: VideoCreateResult + let channelId: number + let publicVideo: VideoCreateResult + let commentId: number + // --------------------------------------------------------------- + + before(async function () { + this.timeout(50000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + latencySetting: { + enabled: false + }, + allowReplay: false + }, + import: { + videos: { + http:{ + enabled: true + } + } + } + } + }) + + userAccessToken = await server.users.generateUserAndToken('user1') + + { + const body = await server.users.getMyInfo() + channelId = body.videoChannels[0].id + } + + { + video = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ 'password1', 'password2' ] + }) + } + path = '/api/v1/videos/' + }) + + async function checkVideoPasswordOptions (options: { + server: PeerTubeServer + token: string + videoPasswords: string[] + expectedStatus: HttpStatusCodeType + mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live' + }) { + const { server, token, videoPasswords, expectedStatus = HttpStatusCode.OK_200, mode } = options + const attaches = { + fixture: buildAbsoluteFixturePath('video_short.webm') + } + const baseCorrectParams = { + name: 'my super name', + category: 5, + licence: 1, + language: 'pt', + nsfw: false, + commentsEnabled: true, + downloadEnabled: true, + waitTranscoding: true, + description: 'my super description', + support: 'my super support text', + tags: [ 'tag1', 'tag2' ], + privacy: VideoPrivacy.PASSWORD_PROTECTED, + channelId, + originallyPublishedAt: new Date().toISOString() + } + if (mode === 'uploadLegacy') { + const fields = { ...baseCorrectParams, videoPasswords } + return checkUploadVideoParam({ server, token, attributes: { ...fields, ...attaches }, expectedStatus, mode: 'legacy' }) + } + + if (mode === 'uploadResumable') { + const fields = { ...baseCorrectParams, videoPasswords } + return checkUploadVideoParam({ server, token, attributes: { ...fields, ...attaches }, expectedStatus, mode: 'resumable' }) + } + + if (mode === 'import') { + const attributes = { ...baseCorrectParams, targetUrl: FIXTURE_URLS.goodVideo, videoPasswords } + return server.imports.importVideo({ attributes, expectedStatus }) + } + + if (mode === 'updateVideo') { + const attributes = { ...baseCorrectParams, videoPasswords } + return server.videos.update({ token, expectedStatus, id: video.id, attributes }) + } + + if (mode === 'updatePasswords') { + return server.videoPasswords.updateAll({ token, expectedStatus, videoId: video.id, passwords: videoPasswords }) + } + + if (mode === 'live') { + const fields = { ...baseCorrectParams, videoPasswords } + + return server.live.create({ fields, expectedStatus }) + } + } + + function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') { + + it('Should fail with a password protected privacy without providing a password', async function () { + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords: undefined, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and an empty password list', async function () { + const videoPasswords = [] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and a too short password', async function () { + const videoPasswords = [ 'p' ] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and a too long password', async function () { + const videoPasswords = [ 'Very very very very very very very very very very very very very very very very very very long password' ] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and an empty password', async function () { + const videoPasswords = [ '' ] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and duplicated passwords', async function () { + const videoPasswords = [ 'password', 'password' ] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + if (mode === 'updatePasswords') { + it('Should fail for an unauthenticated user', async function () { + const videoPasswords = [ 'password' ] + await checkVideoPasswordOptions({ + server, + token: null, + videoPasswords, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401, + mode + }) + }) + + it('Should fail for an unauthorized user', async function () { + const videoPasswords = [ 'password' ] + await checkVideoPasswordOptions({ + server, + token: userAccessToken, + videoPasswords, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode + }) + }) + } + + it('Should succeed with a password protected privacy and correct passwords', async function () { + const videoPasswords = [ 'password1', 'password2' ] + const expectedStatus = mode === 'updatePasswords' || mode === 'updateVideo' + ? HttpStatusCode.NO_CONTENT_204 + : HttpStatusCode.OK_200 + + await checkVideoPasswordOptions({ server, token: server.accessToken, videoPasswords, expectedStatus, mode }) + }) + } + + describe('When adding or updating a video', function () { + describe('Resumable upload', function () { + validateVideoPasswordList('uploadResumable') + }) + + describe('Legacy upload', function () { + validateVideoPasswordList('uploadLegacy') + }) + + describe('When importing a video', function () { + validateVideoPasswordList('import') + }) + + describe('When updating a video', function () { + validateVideoPasswordList('updateVideo') + }) + + describe('When updating the password list of a video', function () { + validateVideoPasswordList('updatePasswords') + }) + + describe('When creating a live', function () { + validateVideoPasswordList('live') + }) + }) + + async function checkVideoAccessOptions (options: { + server: PeerTubeServer + token?: string + videoPassword?: string + expectedStatus: HttpStatusCodeType + mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token' + }) { + const { server, token = null, videoPassword, expectedStatus, mode } = options + + if (mode === 'get') { + return server.videos.get({ id: video.id, expectedStatus }) + } + + if (mode === 'getWithToken') { + return server.videos.getWithToken({ + id: video.id, + token, + expectedStatus + }) + } + + if (mode === 'getWithPassword') { + return server.videos.getWithPassword({ + id: video.id, + token, + expectedStatus, + password: videoPassword + }) + } + + if (mode === 'rate') { + return server.videos.rate({ + id: video.id, + token, + expectedStatus, + rating: 'like', + videoPassword + }) + } + + if (mode === 'createThread') { + const fields = { text: 'super comment' } + const headers = videoPassword !== undefined && videoPassword !== null + ? { 'x-peertube-video-password': videoPassword } + : undefined + const body = await makePostBodyRequest({ + url: server.url, + path: path + video.uuid + '/comment-threads', + token, + fields, + headers, + expectedStatus + }) + return JSON.parse(body.text) + } + + if (mode === 'replyThread') { + const fields = { text: 'super reply' } + const headers = videoPassword !== undefined && videoPassword !== null + ? { 'x-peertube-video-password': videoPassword } + : undefined + return makePostBodyRequest({ + url: server.url, + path: path + video.uuid + '/comments/' + commentId, + token, + fields, + headers, + expectedStatus + }) + } + if (mode === 'listThreads') { + return server.comments.listThreads({ + videoId: video.id, + token, + expectedStatus, + videoPassword + }) + } + + if (mode === 'listCaptions') { + return server.captions.list({ + videoId: video.id, + token, + expectedStatus, + videoPassword + }) + } + + if (mode === 'token') { + return server.videoToken.create({ + videoId: video.id, + token, + expectedStatus, + videoPassword + }) + } + } + + function checkVideoError (error: any, mode: 'providePassword' | 'incorrectPassword') { + const serverCode = mode === 'providePassword' + ? ServerErrorCode.VIDEO_REQUIRES_PASSWORD + : ServerErrorCode.INCORRECT_VIDEO_PASSWORD + + const message = mode === 'providePassword' + ? 'Please provide a password to access this password protected video' + : 'Incorrect video password. Access to the video is denied.' + + if (!error.code) { + error = JSON.parse(error.text) + } + + expect(error.code).to.equal(serverCode) + expect(error.detail).to.equal(message) + expect(error.error).to.equal(message) + + expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403) + } + + function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') { + const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode) + let tokens: string[] + if (!requiresUserAuth) { + it('Should fail without providing a password for an unlogged user', async function () { + const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode }) + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'providePassword') + }) + } + + it('Should fail without providing a password for an unauthorised user', async function () { + const tmp = mode === 'get' ? 'getWithToken' : mode + + const body = await checkVideoAccessOptions({ + server, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode: tmp + }) + + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'providePassword') + }) + + it('Should fail if a wrong password is entered', async function () { + const tmp = mode === 'get' ? 'getWithPassword' : mode + tokens = [ userAccessToken, server.accessToken ] + + if (!requiresUserAuth) tokens.push(null) + + for (const token of tokens) { + const body = await checkVideoAccessOptions({ + server, + token, + videoPassword: 'toto', + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode: tmp + }) + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'incorrectPassword') + } + }) + + it('Should fail if an empty password is entered', async function () { + const tmp = mode === 'get' ? 'getWithPassword' : mode + + for (const token of tokens) { + const body = await checkVideoAccessOptions({ + server, + token, + videoPassword: '', + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode: tmp + }) + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'incorrectPassword') + } + }) + + it('Should fail if an inccorect password containing the correct password is entered', async function () { + const tmp = mode === 'get' ? 'getWithPassword' : mode + + for (const token of tokens) { + const body = await checkVideoAccessOptions({ + server, + token, + videoPassword: 'password11', + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode: tmp + }) + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'incorrectPassword') + } + }) + + it('Should succeed without providing a password for an authorised user', async function () { + const tmp = mode === 'get' ? 'getWithToken' : mode + const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200 + + const body = await checkVideoAccessOptions({ server, token: server.accessToken, expectedStatus, mode: tmp }) + + if (mode === 'createThread') commentId = body.comment.id + }) + + it('Should succeed using correct passwords', async function () { + const tmp = mode === 'get' ? 'getWithPassword' : mode + const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200 + + for (const token of tokens) { + await checkVideoAccessOptions({ server, videoPassword: 'password1', token, expectedStatus, mode: tmp }) + await checkVideoAccessOptions({ server, videoPassword: 'password2', token, expectedStatus, mode: tmp }) + } + }) + } + + describe('When accessing password protected video', function () { + + describe('For getting a password protected video', function () { + validateVideoAccess('get') + }) + + describe('For rating a video', function () { + validateVideoAccess('rate') + }) + + describe('For creating a thread', function () { + validateVideoAccess('createThread') + }) + + describe('For replying to a thread', function () { + validateVideoAccess('replyThread') + }) + + describe('For listing threads', function () { + validateVideoAccess('listThreads') + }) + + describe('For getting captions', function () { + validateVideoAccess('listCaptions') + }) + + describe('For creating video file token', function () { + validateVideoAccess('token') + }) + }) + + describe('When listing passwords', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path + video.uuid + '/passwords', server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path + video.uuid + '/passwords', server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path + video.uuid + '/passwords', server.accessToken) + }) + + it('Should fail for unauthenticated user', async function () { + await server.videoPasswords.list({ + token: null, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401, + videoId: video.id + }) + }) + + it('Should fail for unauthorized user', async function () { + await server.videoPasswords.list({ + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + videoId: video.id + }) + }) + + it('Should succeed with the correct parameters', async function () { + await server.videoPasswords.list({ + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200, + videoId: video.id + }) + }) + }) + + describe('When deleting a password', async function () { + const passwords = (await server.videoPasswords.list({ videoId: video.id })).data + + it('Should fail with wrong password id', async function () { + await server.videoPasswords.remove({ id: -1, videoId: video.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail for unauthenticated user', async function () { + await server.videoPasswords.remove({ + id: passwords[0].id, + token: null, + videoId: video.id, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail for unauthorized user', async function () { + await server.videoPasswords.remove({ + id: passwords[0].id, + token: userAccessToken, + videoId: video.id, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail for non password protected video', async function () { + publicVideo = await server.videos.quickUpload({ name: 'public video' }) + await server.videoPasswords.remove({ id: passwords[0].id, videoId: publicVideo.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail for password not linked to correct video', async function () { + const video2 = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ 'password1', 'password2' ] + }) + await server.videoPasswords.remove({ id: passwords[0].id, videoId: video2.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with correct parameter', async function () { + await server.videoPasswords.remove({ id: passwords[0].id, videoId: video.id, expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + }) + + it('Should fail for last password of a video', async function () { + await server.videoPasswords.remove({ id: passwords[1].id, videoId: video.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-playlists.ts b/packages/tests/src/api/check-params/video-playlists.ts new file mode 100644 index 000000000..7f5be18d4 --- /dev/null +++ b/packages/tests/src/api/check-params/video-playlists.ts @@ -0,0 +1,695 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { + HttpStatusCode, + VideoPlaylistCreate, + VideoPlaylistCreateResult, + VideoPlaylistElementCreate, + VideoPlaylistElementUpdate, + VideoPlaylistPrivacy, + VideoPlaylistReorder, + VideoPlaylistType +} from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + PlaylistsCommand, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test video playlists API validator', function () { + let server: PeerTubeServer + let userAccessToken: string + + let playlist: VideoPlaylistCreateResult + let privatePlaylistUUID: string + + let watchLaterPlaylistId: number + let videoId: number + let elementId: number + + let command: PlaylistsCommand + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + userAccessToken = await server.users.generateUserAndToken('user1') + videoId = (await server.videos.quickUpload({ name: 'video 1' })).id + + command = server.playlists + + { + const { data } = await command.listByAccount({ + token: server.accessToken, + handle: 'root', + start: 0, + count: 5, + playlistType: VideoPlaylistType.WATCH_LATER + }) + watchLaterPlaylistId = data[0].id + } + + { + playlist = await command.create({ + attributes: { + displayName: 'super playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: server.store.channel.id + } + }) + } + + { + const created = await command.create({ + attributes: { + displayName: 'private', + privacy: VideoPlaylistPrivacy.PRIVATE + } + }) + privatePlaylistUUID = created.uuid + } + }) + + describe('When listing playlists', function () { + const globalPath = '/api/v1/video-playlists' + const accountPath = '/api/v1/accounts/root/video-playlists' + const videoChannelPath = '/api/v1/video-channels/root_channel/video-playlists' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, globalPath, server.accessToken) + await checkBadStartPagination(server.url, accountPath, server.accessToken) + await checkBadStartPagination(server.url, videoChannelPath, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, globalPath, server.accessToken) + await checkBadCountPagination(server.url, accountPath, server.accessToken) + await checkBadCountPagination(server.url, videoChannelPath, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, globalPath, server.accessToken) + await checkBadSortPagination(server.url, accountPath, server.accessToken) + await checkBadSortPagination(server.url, videoChannelPath, server.accessToken) + }) + + it('Should fail with a bad playlist type', async function () { + await makeGetRequest({ url: server.url, path: globalPath, query: { playlistType: 3 } }) + await makeGetRequest({ url: server.url, path: accountPath, query: { playlistType: 3 } }) + await makeGetRequest({ url: server.url, path: videoChannelPath, query: { playlistType: 3 } }) + }) + + it('Should fail with a bad account parameter', async function () { + const accountPath = '/api/v1/accounts/root2/video-playlists' + + await makeGetRequest({ + url: server.url, + path: accountPath, + expectedStatus: HttpStatusCode.NOT_FOUND_404, + token: server.accessToken + }) + }) + + it('Should fail with a bad video channel parameter', async function () { + const accountPath = '/api/v1/video-channels/bad_channel/video-playlists' + + await makeGetRequest({ + url: server.url, + path: accountPath, + expectedStatus: HttpStatusCode.NOT_FOUND_404, + token: server.accessToken + }) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path: globalPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken }) + await makeGetRequest({ url: server.url, path: accountPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken }) + await makeGetRequest({ + url: server.url, + path: videoChannelPath, + expectedStatus: HttpStatusCode.OK_200, + token: server.accessToken + }) + }) + }) + + describe('When listing videos of a playlist', function () { + const path = '/api/v1/video-playlists/' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path + playlist.shortUUID + '/videos', server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path + playlist.shortUUID + '/videos', server.accessToken) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path: path + playlist.shortUUID + '/videos', expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When getting a video playlist', function () { + it('Should fail with a bad id or uuid', async function () { + await command.get({ playlistId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown playlist', async function () { + await command.get({ playlistId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail to get an unlisted playlist with the number id', async function () { + const playlist = await command.create({ + attributes: { + displayName: 'super playlist', + videoChannelId: server.store.channel.id, + privacy: VideoPlaylistPrivacy.UNLISTED + } + }) + + await command.get({ playlistId: playlist.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.get({ playlistId: playlist.uuid, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should succeed with the correct params', async function () { + await command.get({ playlistId: playlist.uuid, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When creating/updating a video playlist', function () { + const getBase = ( + attributes?: Partial, + wrapper?: Partial[0]> + ) => { + return { + attributes: { + displayName: 'display name', + privacy: VideoPlaylistPrivacy.UNLISTED, + thumbnailfile: 'custom-thumbnail.jpg', + videoChannelId: server.store.channel.id, + + ...attributes + }, + + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + + ...wrapper + } + } + const getUpdate = (params: any, playlistId: number | string) => { + return { ...params, playlistId } + } + + it('Should fail with an unauthenticated user', async function () { + const params = getBase({}, { token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + + await command.create(params) + await command.update(getUpdate(params, playlist.shortUUID)) + }) + + it('Should fail without displayName', async function () { + const params = getBase({ displayName: undefined }) + + await command.create(params) + }) + + it('Should fail with an incorrect display name', async function () { + const params = getBase({ displayName: 's'.repeat(300) }) + + await command.create(params) + await command.update(getUpdate(params, playlist.shortUUID)) + }) + + it('Should fail with an incorrect description', async function () { + const params = getBase({ description: 't' }) + + await command.create(params) + await command.update(getUpdate(params, playlist.shortUUID)) + }) + + it('Should fail with an incorrect privacy', async function () { + const params = getBase({ privacy: 45 as any }) + + await command.create(params) + await command.update(getUpdate(params, playlist.shortUUID)) + }) + + it('Should fail with an unknown video channel id', async function () { + const params = getBase({ videoChannelId: 42 }, { expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + + await command.create(params) + await command.update(getUpdate(params, playlist.shortUUID)) + }) + + it('Should fail with an incorrect thumbnail file', async function () { + const params = getBase({ thumbnailfile: 'video_short.mp4' }) + + await command.create(params) + await command.update(getUpdate(params, playlist.shortUUID)) + }) + + it('Should fail with a thumbnail file too big', async function () { + const params = getBase({ thumbnailfile: 'custom-preview-big.png' }) + + await command.create(params) + await command.update(getUpdate(params, playlist.shortUUID)) + }) + + it('Should fail to set "public" a playlist not assigned to a channel', async function () { + const params = getBase({ privacy: VideoPlaylistPrivacy.PUBLIC, videoChannelId: undefined }) + const params2 = getBase({ privacy: VideoPlaylistPrivacy.PUBLIC, videoChannelId: 'null' as any }) + const params3 = getBase({ privacy: undefined, videoChannelId: 'null' as any }) + + await command.create(params) + await command.create(params2) + await command.update(getUpdate(params, privatePlaylistUUID)) + await command.update(getUpdate(params2, playlist.shortUUID)) + await command.update(getUpdate(params3, playlist.shortUUID)) + }) + + it('Should fail with an unknown playlist to update', async function () { + await command.update(getUpdate( + getBase({}, { expectedStatus: HttpStatusCode.NOT_FOUND_404 }), + 42 + )) + }) + + it('Should fail to update a playlist of another user', async function () { + await command.update(getUpdate( + getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }), + playlist.shortUUID + )) + }) + + it('Should fail to update the watch later playlist', async function () { + await command.update(getUpdate( + getBase({}, { expectedStatus: HttpStatusCode.BAD_REQUEST_400 }), + watchLaterPlaylistId + )) + }) + + it('Should succeed with the correct params', async function () { + { + const params = getBase({}, { expectedStatus: HttpStatusCode.OK_200 }) + await command.create(params) + } + + { + const params = getBase({}, { expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + await command.update(getUpdate(params, playlist.shortUUID)) + } + }) + }) + + describe('When adding an element in a playlist', function () { + const getBase = ( + attributes?: Partial, + wrapper?: Partial[0]> + ) => { + return { + attributes: { + videoId, + startTimestamp: 2, + stopTimestamp: 3, + + ...attributes + }, + + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + playlistId: playlist.id, + + ...wrapper + } + } + + it('Should fail with an unauthenticated user', async function () { + const params = getBase({}, { token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await command.addElement(params) + }) + + it('Should fail with the playlist of another user', async function () { + const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await command.addElement(params) + }) + + it('Should fail with an unknown or incorrect playlist id', async function () { + { + const params = getBase({}, { playlistId: 'toto' }) + await command.addElement(params) + } + + { + const params = getBase({}, { playlistId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.addElement(params) + } + }) + + it('Should fail with an unknown or incorrect video id', async function () { + const params = getBase({ videoId: 42 }, { expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.addElement(params) + }) + + it('Should fail with a bad start/stop timestamp', async function () { + { + const params = getBase({ startTimestamp: -42 }) + await command.addElement(params) + } + + { + const params = getBase({ stopTimestamp: 'toto' as any }) + await command.addElement(params) + } + }) + + it('Succeed with the correct params', async function () { + const params = getBase({}, { expectedStatus: HttpStatusCode.OK_200 }) + const created = await command.addElement(params) + elementId = created.id + }) + }) + + describe('When updating an element in a playlist', function () { + const getBase = ( + attributes?: Partial, + wrapper?: Partial[0]> + ) => { + return { + attributes: { + startTimestamp: 1, + stopTimestamp: 2, + + ...attributes + }, + + elementId, + playlistId: playlist.id, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + + ...wrapper + } + } + + it('Should fail with an unauthenticated user', async function () { + const params = getBase({}, { token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await command.updateElement(params) + }) + + it('Should fail with the playlist of another user', async function () { + const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await command.updateElement(params) + }) + + it('Should fail with an unknown or incorrect playlist id', async function () { + { + const params = getBase({}, { playlistId: 'toto' }) + await command.updateElement(params) + } + + { + const params = getBase({}, { playlistId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.updateElement(params) + } + }) + + it('Should fail with an unknown or incorrect playlistElement id', async function () { + { + const params = getBase({}, { elementId: 'toto' }) + await command.updateElement(params) + } + + { + const params = getBase({}, { elementId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.updateElement(params) + } + }) + + it('Should fail with a bad start/stop timestamp', async function () { + { + const params = getBase({ startTimestamp: 'toto' as any }) + await command.updateElement(params) + } + + { + const params = getBase({ stopTimestamp: -42 }) + await command.updateElement(params) + } + }) + + it('Should fail with an unknown element', async function () { + const params = getBase({}, { elementId: 888, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.updateElement(params) + }) + + it('Succeed with the correct params', async function () { + const params = getBase({}, { expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + await command.updateElement(params) + }) + }) + + describe('When reordering elements of a playlist', function () { + let videoId3: number + let videoId4: number + + const getBase = ( + attributes?: Partial, + wrapper?: Partial[0]> + ) => { + return { + attributes: { + startPosition: 1, + insertAfterPosition: 2, + reorderLength: 3, + + ...attributes + }, + + playlistId: playlist.shortUUID, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + + ...wrapper + } + } + + before(async function () { + videoId3 = (await server.videos.quickUpload({ name: 'video 3' })).id + videoId4 = (await server.videos.quickUpload({ name: 'video 4' })).id + + for (const id of [ videoId3, videoId4 ]) { + await command.addElement({ playlistId: playlist.shortUUID, attributes: { videoId: id } }) + } + }) + + it('Should fail with an unauthenticated user', async function () { + const params = getBase({}, { token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await command.reorderElements(params) + }) + + it('Should fail with the playlist of another user', async function () { + const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await command.reorderElements(params) + }) + + it('Should fail with an invalid playlist', async function () { + { + const params = getBase({}, { playlistId: 'toto' }) + await command.reorderElements(params) + } + + { + const params = getBase({}, { playlistId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.reorderElements(params) + } + }) + + it('Should fail with an invalid start position', async function () { + { + const params = getBase({ startPosition: -1 }) + await command.reorderElements(params) + } + + { + const params = getBase({ startPosition: 'toto' as any }) + await command.reorderElements(params) + } + + { + const params = getBase({ startPosition: 42 }) + await command.reorderElements(params) + } + }) + + it('Should fail with an invalid insert after position', async function () { + { + const params = getBase({ insertAfterPosition: 'toto' as any }) + await command.reorderElements(params) + } + + { + const params = getBase({ insertAfterPosition: -2 }) + await command.reorderElements(params) + } + + { + const params = getBase({ insertAfterPosition: 42 }) + await command.reorderElements(params) + } + }) + + it('Should fail with an invalid reorder length', async function () { + { + const params = getBase({ reorderLength: 'toto' as any }) + await command.reorderElements(params) + } + + { + const params = getBase({ reorderLength: -2 }) + await command.reorderElements(params) + } + + { + const params = getBase({ reorderLength: 42 }) + await command.reorderElements(params) + } + }) + + it('Succeed with the correct params', async function () { + const params = getBase({}, { expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + await command.reorderElements(params) + }) + }) + + describe('When checking exists in playlist endpoint', function () { + const path = '/api/v1/users/me/video-playlists/videos-exist' + + it('Should fail with an unauthenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { videoIds: [ 1, 2 ] }, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with invalid video ids', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path, + query: { videoIds: 'toto' } + }) + + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path, + query: { videoIds: [ 'toto' ] } + }) + + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path, + query: { videoIds: [ 1, 'toto' ] } + }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path, + query: { videoIds: [ 1, 2 ] }, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + + describe('When deleting an element in a playlist', function () { + const getBase = (wrapper: Partial[0]>) => { + return { + elementId, + playlistId: playlist.uuid, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + + ...wrapper + } + } + + it('Should fail with an unauthenticated user', async function () { + const params = getBase({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await command.removeElement(params) + }) + + it('Should fail with the playlist of another user', async function () { + const params = getBase({ token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await command.removeElement(params) + }) + + it('Should fail with an unknown or incorrect playlist id', async function () { + { + const params = getBase({ playlistId: 'toto' }) + await command.removeElement(params) + } + + { + const params = getBase({ playlistId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.removeElement(params) + } + }) + + it('Should fail with an unknown or incorrect video id', async function () { + { + const params = getBase({ elementId: 'toto' as any }) + await command.removeElement(params) + } + + { + const params = getBase({ elementId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.removeElement(params) + } + }) + + it('Should fail with an unknown element', async function () { + const params = getBase({ elementId: 888, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await command.removeElement(params) + }) + + it('Succeed with the correct params', async function () { + const params = getBase({ expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + await command.removeElement(params) + }) + }) + + describe('When deleting a playlist', function () { + it('Should fail with an unknown playlist', async function () { + await command.delete({ playlistId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a playlist of another user', async function () { + await command.delete({ token: userAccessToken, playlistId: playlist.uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with the watch later playlist', async function () { + await command.delete({ playlistId: watchLaterPlaylistId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with the correct params', async function () { + await command.delete({ playlistId: playlist.uuid }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-source.ts b/packages/tests/src/api/check-params/video-source.ts new file mode 100644 index 000000000..918182b8d --- /dev/null +++ b/packages/tests/src/api/check-params/video-source.ts @@ -0,0 +1,154 @@ +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video sources API validator', function () { + let server: PeerTubeServer = null + let uuid: string + let userToken: string + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + userToken = await server.users.generateUserAndToken('user1') + }) + + describe('When getting latest source', function () { + + before(async function () { + const created = await server.videos.quickUpload({ name: 'video' }) + uuid = created.uuid + }) + + it('Should fail without a valid uuid', async function () { + await server.videos.getSource({ id: '4da6fde3-88f7-4d16-b119-108df563d0b0', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should receive 404 when passing a non existing video id', async function () { + await server.videos.getSource({ id: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should not get the source as unauthenticated', async function () { + await server.videos.getSource({ id: uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, token: null }) + }) + + it('Should not get the source with another user', async function () { + await server.videos.getSource({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: userToken }) + }) + + it('Should succeed with the correct parameters get the source as another user', async function () { + await server.videos.getSource({ id: uuid }) + }) + }) + + describe('When updating source video file', function () { + let userAccessToken: string + let userId: number + + let videoId: string + let userVideoId: string + + before(async function () { + const res = await server.users.generate('user2') + userAccessToken = res.token + userId = res.userId + + const { uuid } = await server.videos.quickUpload({ name: 'video' }) + videoId = uuid + + await waitJobs([ server ]) + }) + + it('Should fail if not enabled on the instance', async function () { + await server.config.disableFileUpdate() + + await server.videos.replaceSourceFile({ videoId, fixture: 'video_short.mp4', expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail on an unknown video', async function () { + await server.config.enableFileUpdate() + + await server.videos.replaceSourceFile({ videoId: 404, fixture: 'video_short.mp4', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with an invalid video', async function () { + await server.config.enableLive({ allowReplay: false }) + + const { video } = await server.live.quickCreate({ saveReplay: false, permanentLive: true }) + await server.videos.replaceSourceFile({ + videoId: video.uuid, + fixture: 'video_short.mp4', + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail without token', async function () { + await server.videos.replaceSourceFile({ + token: null, + videoId, + fixture: 'video_short.mp4', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with another user', async function () { + await server.videos.replaceSourceFile({ + token: userAccessToken, + videoId, + fixture: 'video_short.mp4', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an incorrect input file', async function () { + await server.videos.replaceSourceFile({ + fixture: 'video_short_fake.webm', + videoId, + completedExpectedStatus: HttpStatusCode.UNPROCESSABLE_ENTITY_422 + }) + + await server.videos.replaceSourceFile({ + fixture: 'video_short.mkv', + videoId, + expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 + }) + }) + + it('Should fail if quota is exceeded', async function () { + this.timeout(60000) + + const { uuid } = await server.videos.quickUpload({ name: 'user video' }) + userVideoId = uuid + await waitJobs([ server ]) + + await server.users.update({ userId, videoQuota: 1 }) + await server.videos.replaceSourceFile({ + token: userAccessToken, + videoId: uuid, + fixture: 'video_short.mp4', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + this.timeout(60000) + + await server.users.update({ userId, videoQuota: 1000 * 1000 * 1000 }) + await server.videos.replaceSourceFile({ videoId: userVideoId, fixture: 'video_short.mp4' }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-storyboards.ts b/packages/tests/src/api/check-params/video-storyboards.ts new file mode 100644 index 000000000..f83b541d8 --- /dev/null +++ b/packages/tests/src/api/check-params/video-storyboards.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' + +describe('Test video storyboards API validator', function () { + let server: PeerTubeServer + + let publicVideo: { uuid: string } + let privateVideo: { uuid: string } + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + publicVideo = await server.videos.quickUpload({ name: 'public' }) + privateVideo = await server.videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }) + }) + + it('Should fail without a valid uuid', async function () { + await server.storyboard.list({ id: '4da6fde3-88f7-4d16-b119-108df563d0b0', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should receive 404 when passing a non existing video id', async function () { + await server.storyboard.list({ id: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should not get the private storyboard without the appropriate token', async function () { + await server.storyboard.list({ id: privateVideo.uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, token: null }) + await server.storyboard.list({ id: publicVideo.uuid, expectedStatus: HttpStatusCode.OK_200, token: null }) + }) + + it('Should succeed with the correct parameters', async function () { + await server.storyboard.list({ id: privateVideo.uuid }) + await server.storyboard.list({ id: publicVideo.uuid }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-studio.ts b/packages/tests/src/api/check-params/video-studio.ts new file mode 100644 index 000000000..ae83f3590 --- /dev/null +++ b/packages/tests/src/api/check-params/video-studio.ts @@ -0,0 +1,392 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, HttpStatusCodeType, VideoStudioTask } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + VideoStudioCommand, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video studio API validator', function () { + let server: PeerTubeServer + let command: VideoStudioCommand + let userAccessToken: string + let videoUUID: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120_000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + userAccessToken = await server.users.generateUserAndToken('user1') + + await server.config.enableMinimumTranscoding() + + const { uuid } = await server.videos.quickUpload({ name: 'video' }) + videoUUID = uuid + + command = server.videoStudio + + await waitJobs([ server ]) + }) + + describe('Task creation', function () { + + describe('Config settings', function () { + + it('Should fail if studio is disabled', async function () { + await server.config.updateExistingSubConfig({ + newConfig: { + videoStudio: { + enabled: false + } + } + }) + + await command.createEditionTasks({ + videoId: videoUUID, + tasks: VideoStudioCommand.getComplexTask(), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to enable studio if transcoding is disabled', async function () { + await server.config.updateExistingSubConfig({ + newConfig: { + videoStudio: { + enabled: true + }, + transcoding: { + enabled: false + } + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed to enable video studio', async function () { + await server.config.updateExistingSubConfig({ + newConfig: { + videoStudio: { + enabled: true + }, + transcoding: { + enabled: true + } + } + }) + }) + }) + + describe('Common tasks', function () { + + it('Should fail without token', async function () { + await command.createEditionTasks({ + token: null, + videoId: videoUUID, + tasks: VideoStudioCommand.getComplexTask(), + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with another user token', async function () { + await command.createEditionTasks({ + token: userAccessToken, + videoId: videoUUID, + tasks: VideoStudioCommand.getComplexTask(), + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid video', async function () { + await command.createEditionTasks({ + videoId: 'tintin', + tasks: VideoStudioCommand.getComplexTask(), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an unknown video', async function () { + await command.createEditionTasks({ + videoId: 42, + tasks: VideoStudioCommand.getComplexTask(), + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with an already in transcoding state video', async function () { + this.timeout(60000) + + const { uuid } = await server.videos.quickUpload({ name: 'transcoded video' }) + await waitJobs([ server ]) + + await server.jobs.pauseJobQueue() + await server.videos.runTranscoding({ videoId: uuid, transcodingType: 'hls' }) + + await command.createEditionTasks({ + videoId: uuid, + tasks: VideoStudioCommand.getComplexTask(), + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + + await server.jobs.resumeJobQueue() + }) + + it('Should fail with a bad complex task', async function () { + await command.createEditionTasks({ + videoId: videoUUID, + tasks: [ + { + name: 'cut', + options: { + start: 1, + end: 2 + } + }, + { + name: 'hadock', + options: { + start: 1, + end: 2 + } + } + ] as any, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail without task', async function () { + await command.createEditionTasks({ + videoId: videoUUID, + tasks: [], + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with too many tasks', async function () { + const tasks: VideoStudioTask[] = [] + + for (let i = 0; i < 110; i++) { + tasks.push({ + name: 'cut', + options: { + start: 1 + } + }) + } + + await command.createEditionTasks({ + videoId: videoUUID, + tasks, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with correct parameters', async function () { + await server.jobs.pauseJobQueue() + + await command.createEditionTasks({ + videoId: videoUUID, + tasks: VideoStudioCommand.getComplexTask(), + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + + it('Should fail with a video that is already waiting for edition', async function () { + this.timeout(120000) + + await command.createEditionTasks({ + videoId: videoUUID, + tasks: VideoStudioCommand.getComplexTask(), + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + + await server.jobs.resumeJobQueue() + + await waitJobs([ server ]) + }) + }) + + describe('Cut task', function () { + + async function cut (start: number, end: number, expectedStatus: HttpStatusCodeType = HttpStatusCode.BAD_REQUEST_400) { + await command.createEditionTasks({ + videoId: videoUUID, + tasks: [ + { + name: 'cut', + options: { + start, + end + } + } + ], + expectedStatus + }) + } + + it('Should fail with bad start/end', async function () { + const invalid = [ + 'tintin', + -1, + undefined + ] + + for (const value of invalid) { + await cut(value as any, undefined) + await cut(undefined, value as any) + } + }) + + it('Should fail with the same start/end', async function () { + await cut(2, 2) + }) + + it('Should fail with inconsistents start/end', async function () { + await cut(2, 1) + }) + + it('Should fail without start and end', async function () { + await cut(undefined, undefined) + }) + + it('Should succeed with the correct params', async function () { + this.timeout(120000) + + await cut(0, 2, HttpStatusCode.NO_CONTENT_204) + + await waitJobs([ server ]) + }) + }) + + describe('Watermark task', function () { + + async function addWatermark (file: string, expectedStatus: HttpStatusCodeType = HttpStatusCode.BAD_REQUEST_400) { + await command.createEditionTasks({ + videoId: videoUUID, + tasks: [ + { + name: 'add-watermark', + options: { + file + } + } + ], + expectedStatus + }) + } + + it('Should fail without waterkmark', async function () { + await addWatermark(undefined) + }) + + it('Should fail with an invalid watermark', async function () { + await addWatermark('video_short.mp4') + }) + + it('Should succeed with the correct params', async function () { + this.timeout(120000) + + await addWatermark('custom-thumbnail.jpg', HttpStatusCode.NO_CONTENT_204) + + await waitJobs([ server ]) + }) + }) + + describe('Intro/Outro task', function () { + + async function addIntroOutro ( + type: 'add-intro' | 'add-outro', + file: string, + expectedStatus: HttpStatusCodeType = HttpStatusCode.BAD_REQUEST_400 + ) { + await command.createEditionTasks({ + videoId: videoUUID, + tasks: [ + { + name: type, + options: { + file + } + } + ], + expectedStatus + }) + } + + it('Should fail without file', async function () { + await addIntroOutro('add-intro', undefined) + await addIntroOutro('add-outro', undefined) + }) + + it('Should fail with an invalid file', async function () { + await addIntroOutro('add-intro', 'custom-thumbnail.jpg') + await addIntroOutro('add-outro', 'custom-thumbnail.jpg') + }) + + it('Should fail with a file that does not contain video stream', async function () { + await addIntroOutro('add-intro', 'sample.ogg') + await addIntroOutro('add-outro', 'sample.ogg') + + }) + + it('Should succeed with the correct params', async function () { + this.timeout(120000) + + await addIntroOutro('add-intro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204) + await waitJobs([ server ]) + + await addIntroOutro('add-outro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204) + await waitJobs([ server ]) + }) + + it('Should check total quota when creating the task', async function () { + this.timeout(120000) + + const user = await server.users.create({ username: 'user_quota_1' }) + const token = await server.login.getAccessToken('user_quota_1') + const { uuid } = await server.videos.quickUpload({ token, name: 'video_quota_1', fixture: 'video_short.mp4' }) + + const addIntroOutroByUser = (type: 'add-intro' | 'add-outro', expectedStatus: HttpStatusCodeType) => { + return command.createEditionTasks({ + token, + videoId: uuid, + tasks: [ + { + name: type, + options: { + file: 'video_short.mp4' + } + } + ], + expectedStatus + }) + } + + await waitJobs([ server ]) + + const { videoQuotaUsed } = await server.users.getMyQuotaUsed({ token }) + await server.users.update({ userId: user.id, videoQuota: Math.round(videoQuotaUsed * 2.5) }) + + // Still valid + await addIntroOutroByUser('add-intro', HttpStatusCode.NO_CONTENT_204) + + await waitJobs([ server ]) + + // Too much quota + await addIntroOutroByUser('add-intro', HttpStatusCode.PAYLOAD_TOO_LARGE_413) + await addIntroOutroByUser('add-outro', HttpStatusCode.PAYLOAD_TOO_LARGE_413) + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/video-token.ts b/packages/tests/src/api/check-params/video-token.ts new file mode 100644 index 000000000..5f838102d --- /dev/null +++ b/packages/tests/src/api/check-params/video-token.ts @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' + +describe('Test video tokens', function () { + let server: PeerTubeServer + let privateVideoId: string + let passwordProtectedVideoId: string + let userToken: string + + const videoPassword = 'password' + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(300_000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + { + const { uuid } = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE }) + privateVideoId = uuid + } + { + const { uuid } = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + passwordProtectedVideoId = uuid + } + userToken = await server.users.generateUserAndToken('user1') + }) + + it('Should not generate tokens on private video for unauthenticated user', async function () { + await server.videoToken.create({ videoId: privateVideoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not generate tokens of unknown video', async function () { + await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should not generate tokens with incorrect password', async function () { + await server.videoToken.create({ + videoId: passwordProtectedVideoId, + token: null, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + videoPassword: 'incorrectPassword' + }) + }) + + it('Should not generate tokens of a non owned video', async function () { + await server.videoToken.create({ videoId: privateVideoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should generate token', async function () { + await server.videoToken.create({ videoId: privateVideoId }) + }) + + it('Should generate token on password protected video', async function () { + await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: null }) + await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: userToken }) + await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/videos-common-filters.ts b/packages/tests/src/api/check-params/videos-common-filters.ts new file mode 100644 index 000000000..dbae3010c --- /dev/null +++ b/packages/tests/src/api/check-params/videos-common-filters.ts @@ -0,0 +1,171 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { + HttpStatusCode, + HttpStatusCodeType, + UserRole, + VideoInclude, + VideoIncludeType, + VideoPrivacy, + VideoPrivacyType +} from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test video filters validators', function () { + let server: PeerTubeServer + let userAccessToken: string + let moderatorAccessToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + const user = { username: 'user1', password: 'my super password' } + await server.users.create({ username: user.username, password: user.password }) + userAccessToken = await server.login.getAccessToken(user) + + const moderator = { username: 'moderator', password: 'my super password' } + await server.users.create({ username: moderator.username, password: moderator.password, role: UserRole.MODERATOR }) + + moderatorAccessToken = await server.login.getAccessToken(moderator) + }) + + describe('When setting video filters', function () { + + const validIncludes = [ + VideoInclude.NONE, + VideoInclude.BLOCKED_OWNER, + VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED + ] + + async function testEndpoints (options: { + token?: string + isLocal?: boolean + include?: VideoIncludeType + privacyOneOf?: VideoPrivacyType[] + expectedStatus: HttpStatusCodeType + excludeAlreadyWatched?: boolean + unauthenticatedUser?: boolean + }) { + const paths = [ + '/api/v1/video-channels/root_channel/videos', + '/api/v1/accounts/root/videos', + '/api/v1/videos', + '/api/v1/search/videos' + ] + + for (const path of paths) { + const token = options.unauthenticatedUser + ? undefined + : options.token || server.accessToken + + await makeGetRequest({ + url: server.url, + path, + token, + query: { + isLocal: options.isLocal, + privacyOneOf: options.privacyOneOf, + include: options.include, + excludeAlreadyWatched: options.excludeAlreadyWatched + }, + expectedStatus: options.expectedStatus + }) + } + } + + it('Should fail with a bad privacyOneOf', async function () { + await testEndpoints({ privacyOneOf: [ 'toto' ] as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with a good privacyOneOf', async function () { + await testEndpoints({ privacyOneOf: [ VideoPrivacy.INTERNAL ], expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should fail to use privacyOneOf with a simple user', async function () { + await testEndpoints({ + privacyOneOf: [ VideoPrivacy.INTERNAL ], + token: userAccessToken, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad include', async function () { + await testEndpoints({ include: 'toto' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with a good include', async function () { + for (const include of validIncludes) { + await testEndpoints({ include, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should fail to include more videos with a simple user', async function () { + for (const include of validIncludes) { + await testEndpoints({ token: userAccessToken, include, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + } + }) + + it('Should succeed to list all local/all with a moderator', async function () { + for (const include of validIncludes) { + await testEndpoints({ token: moderatorAccessToken, include, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should succeed to list all local/all with an admin', async function () { + for (const include of validIncludes) { + await testEndpoints({ token: server.accessToken, include, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + // Because we cannot authenticate the user on the RSS endpoint + it('Should fail on the feeds endpoint with the all filter', async function () { + for (const include of [ VideoInclude.NOT_PUBLISHED_STATE ]) { + await makeGetRequest({ + url: server.url, + path: '/feeds/videos.json', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401, + query: { + include + } + }) + } + }) + + it('Should succeed on the feeds endpoint with the local filter', async function () { + await makeGetRequest({ + url: server.url, + path: '/feeds/videos.json', + expectedStatus: HttpStatusCode.OK_200, + query: { + isLocal: true + } + }) + }) + + it('Should fail when trying to exclude already watched videos for an unlogged user', async function () { + await testEndpoints({ excludeAlreadyWatched: true, unauthenticatedUser: true, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed when trying to exclude already watched videos for a logged user', async function () { + await testEndpoints({ token: userAccessToken, excludeAlreadyWatched: true, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/videos-history.ts b/packages/tests/src/api/check-params/videos-history.ts new file mode 100644 index 000000000..65d1e9fac --- /dev/null +++ b/packages/tests/src/api/check-params/videos-history.ts @@ -0,0 +1,145 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { checkBadCountPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + makePutBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test videos history API validator', function () { + const myHistoryPath = '/api/v1/users/me/history/videos' + const myHistoryRemove = myHistoryPath + '/remove' + let viewPath: string + let server: PeerTubeServer + let videoId: number + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const { id, uuid } = await server.videos.upload() + viewPath = '/api/v1/videos/' + uuid + '/views' + videoId = id + }) + + describe('When notifying a user is watching a video', function () { + + it('Should fail with a bad token', async function () { + const fields = { currentTime: 5 } + await makePutBodyRequest({ url: server.url, path: viewPath, fields, token: 'bad', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should succeed with the correct parameters', async function () { + const fields = { currentTime: 5 } + + await makePutBodyRequest({ + url: server.url, + path: viewPath, + fields, + token: server.accessToken, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When listing user videos history', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, myHistoryPath, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, myHistoryPath, server.accessToken) + }) + + it('Should fail with an unauthenticated user', async function () { + await makeGetRequest({ url: server.url, path: myHistoryPath, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ url: server.url, token: server.accessToken, path: myHistoryPath, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When removing a specific user video history element', function () { + let path: string + + before(function () { + path = myHistoryPath + '/' + videoId + }) + + it('Should fail with an unauthenticated user', async function () { + await makeDeleteRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a bad videoId parameter', async function () { + await makeDeleteRequest({ + url: server.url, + token: server.accessToken, + path: myHistoryRemove + '/hi', + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeDeleteRequest({ + url: server.url, + token: server.accessToken, + path, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When removing all user videos history', function () { + it('Should fail with an unauthenticated user', async function () { + await makePostBodyRequest({ url: server.url, path: myHistoryPath + '/remove', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a bad beforeDate parameter', async function () { + const body = { beforeDate: '15' } + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path: myHistoryRemove, + fields: body, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with a valid beforeDate param', async function () { + const body = { beforeDate: new Date().toISOString() } + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path: myHistoryRemove, + fields: body, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + + it('Should succeed without body', async function () { + await makePostBodyRequest({ + url: server.url, + token: server.accessToken, + path: myHistoryRemove, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/videos-overviews.ts b/packages/tests/src/api/check-params/videos-overviews.ts new file mode 100644 index 000000000..ba6f6ac69 --- /dev/null +++ b/packages/tests/src/api/check-params/videos-overviews.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { cleanupTests, createSingleServer, PeerTubeServer } from '@peertube/peertube-server-commands' + +describe('Test videos overview API validator', function () { + let server: PeerTubeServer + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + }) + + describe('When getting videos overview', function () { + + it('Should fail with a bad pagination', async function () { + await server.overviews.getVideos({ page: 0, expectedStatus: 400 }) + await server.overviews.getVideos({ page: 100, expectedStatus: 400 }) + }) + + it('Should succeed with a good pagination', async function () { + await server.overviews.getVideos({ page: 1 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/videos.ts b/packages/tests/src/api/check-params/videos.ts new file mode 100644 index 000000000..c349ed9fe --- /dev/null +++ b/packages/tests/src/api/check-params/videos.ts @@ -0,0 +1,883 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { join } from 'path' +import { omit, randomInt } from '@peertube/peertube-core-utils' +import { HttpStatusCode, PeerTubeProblemDocument, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createSingleServer, + makeDeleteRequest, + makeGetRequest, + makePutBodyRequest, + makeUploadRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { checkBadStartPagination, checkBadCountPagination, checkBadSortPagination } from '@tests/shared/checks.js' +import { checkUploadVideoParam } from '@tests/shared/videos.js' + +describe('Test videos API validator', function () { + const path = '/api/v1/videos/' + let server: PeerTubeServer + let userAccessToken = '' + let accountName: string + let channelId: number + let channelName: string + let video: VideoCreateResult + let privateVideo: VideoCreateResult + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + userAccessToken = await server.users.generateUserAndToken('user1') + + { + const body = await server.users.getMyInfo() + channelId = body.videoChannels[0].id + channelName = body.videoChannels[0].name + accountName = body.account.name + '@' + body.account.host + } + + { + privateVideo = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE }) + } + }) + + describe('When listing videos', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path) + }) + + it('Should fail with a bad skipVideos query', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200, query: { skipCount: 'toto' } }) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200, query: { skipCount: false } }) + }) + }) + + describe('When searching a video', function () { + + it('Should fail with nothing', async function () { + await makeGetRequest({ + url: server.url, + path: join(path, 'search'), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, join(path, 'search', 'test')) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, join(path, 'search', 'test')) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, join(path, 'search', 'test')) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When listing my videos', function () { + const path = '/api/v1/users/me/videos' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an invalid channel', async function () { + await makeGetRequest({ url: server.url, token: server.accessToken, path, query: { channelId: 'toto' } }) + }) + + it('Should fail with an unknown channel', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path, + query: { channelId: 89898 }, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ url: server.url, token: server.accessToken, path, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When listing account videos', function () { + let path: string + + before(async function () { + path = '/api/v1/accounts/' + accountName + '/videos' + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When listing video channel videos', function () { + let path: string + + before(async function () { + path = '/api/v1/video-channels/' + channelName + '/videos' + }) + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should success with the correct parameters', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('When adding a video', function () { + let baseCorrectParams + const baseCorrectAttaches = { + fixture: buildAbsoluteFixturePath('video_short.webm') + } + + before(function () { + // Put in before to have channelId + baseCorrectParams = { + name: 'my super name', + category: 5, + licence: 1, + language: 'pt', + nsfw: false, + commentsEnabled: true, + downloadEnabled: true, + waitTranscoding: true, + description: 'my super description', + support: 'my super support text', + tags: [ 'tag1', 'tag2' ], + privacy: VideoPrivacy.PUBLIC, + channelId, + originallyPublishedAt: new Date().toISOString() + } + }) + + function runSuite (mode: 'legacy' | 'resumable') { + + const baseOptions = () => { + return { + server, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + } + } + + it('Should fail with nothing', async function () { + const fields = {} + const attaches = {} + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail without name', async function () { + const fields = omit(baseCorrectParams, [ 'name' ]) + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a long name', async function () { + const fields = { ...baseCorrectParams, name: 'super'.repeat(65) } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a bad category', async function () { + const fields = { ...baseCorrectParams, category: 125 } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a bad licence', async function () { + const fields = { ...baseCorrectParams, licence: 125 } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a bad language', async function () { + const fields = { ...baseCorrectParams, language: 'a'.repeat(15) } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a long description', async function () { + const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a long support text', async function () { + const fields = { ...baseCorrectParams, support: 'super'.repeat(201) } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail without a channel', async function () { + const fields = omit(baseCorrectParams, [ 'channelId' ]) + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a bad channel', async function () { + const fields = { ...baseCorrectParams, channelId: 545454 } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with another user channel', async function () { + const user = { + username: 'fake' + randomInt(0, 1500), + password: 'fake_password' + } + await server.users.create({ username: user.username, password: user.password }) + + const accessTokenUser = await server.login.getAccessToken(user) + const { videoChannels } = await server.users.getMyInfo({ token: accessTokenUser }) + const customChannelId = videoChannels[0].id + + const fields = { ...baseCorrectParams, channelId: customChannelId } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ + ...baseOptions(), + token: userAccessToken, + attributes: { ...fields, ...attaches } + }) + }) + + it('Should fail with too many tags', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a tag length too low', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 't' ] } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a tag length too big', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a bad schedule update (miss updateAt)', async function () { + const fields = { ...baseCorrectParams, scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a bad schedule update (wrong updateAt)', async function () { + const fields = { + ...baseCorrectParams, + + scheduleUpdate: { + privacy: VideoPrivacy.PUBLIC, + updateAt: 'toto' + } + } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a bad originally published at attribute', async function () { + const fields = { ...baseCorrectParams, originallyPublishedAt: 'toto' } + const attaches = baseCorrectAttaches + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail without an input file', async function () { + const fields = baseCorrectParams + const attaches = {} + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with an incorrect input file', async function () { + const fields = baseCorrectParams + let attaches = { fixture: buildAbsoluteFixturePath('video_short_fake.webm') } + + await checkUploadVideoParam({ + ...baseOptions(), + attributes: { ...fields, ...attaches }, + // 200 for the init request, 422 when the file has finished being uploaded + expectedStatus: undefined, + completedExpectedStatus: HttpStatusCode.UNPROCESSABLE_ENTITY_422 + }) + + attaches = { fixture: buildAbsoluteFixturePath('video_short.mkv') } + await checkUploadVideoParam({ + ...baseOptions(), + attributes: { ...fields, ...attaches }, + expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 + }) + }) + + it('Should fail with an incorrect thumbnail file', async function () { + const fields = baseCorrectParams + const attaches = { + thumbnailfile: buildAbsoluteFixturePath('video_short.mp4'), + fixture: buildAbsoluteFixturePath('video_short.mp4') + } + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a big thumbnail file', async function () { + const fields = baseCorrectParams + const attaches = { + thumbnailfile: buildAbsoluteFixturePath('custom-preview-big.png'), + fixture: buildAbsoluteFixturePath('video_short.mp4') + } + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with an incorrect preview file', async function () { + const fields = baseCorrectParams + const attaches = { + previewfile: buildAbsoluteFixturePath('video_short.mp4'), + fixture: buildAbsoluteFixturePath('video_short.mp4') + } + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should fail with a big preview file', async function () { + const fields = baseCorrectParams + const attaches = { + previewfile: buildAbsoluteFixturePath('custom-preview-big.png'), + fixture: buildAbsoluteFixturePath('video_short.mp4') + } + + await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } }) + }) + + it('Should report the appropriate error', async function () { + const fields = { ...baseCorrectParams, language: 'a'.repeat(15) } + const attaches = baseCorrectAttaches + + const attributes = { ...fields, ...attaches } + const body = await checkUploadVideoParam({ ...baseOptions(), attributes }) + + const error = body as unknown as PeerTubeProblemDocument + + if (mode === 'legacy') { + expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/uploadLegacy') + } else { + expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/uploadResumableInit') + } + + expect(error.type).to.equal('about:blank') + expect(error.title).to.equal('Bad Request') + + expect(error.detail).to.equal('Incorrect request parameters: language') + expect(error.error).to.equal('Incorrect request parameters: language') + + expect(error.status).to.equal(HttpStatusCode.BAD_REQUEST_400) + expect(error['invalid-params'].language).to.exist + }) + + it('Should succeed with the correct parameters', async function () { + this.timeout(30000) + + const fields = baseCorrectParams + + { + const attaches = baseCorrectAttaches + await checkUploadVideoParam({ + ...baseOptions(), + attributes: { ...fields, ...attaches }, + expectedStatus: HttpStatusCode.OK_200 + }) + } + + { + const attaches = { + ...baseCorrectAttaches, + + videofile: buildAbsoluteFixturePath('video_short.mp4') + } + + await checkUploadVideoParam({ + ...baseOptions(), + attributes: { ...fields, ...attaches }, + expectedStatus: HttpStatusCode.OK_200 + }) + } + + { + const attaches = { + ...baseCorrectAttaches, + + videofile: buildAbsoluteFixturePath('video_short.ogv') + } + + await checkUploadVideoParam({ + ...baseOptions(), + attributes: { ...fields, ...attaches }, + expectedStatus: HttpStatusCode.OK_200 + }) + } + }) + } + + describe('Resumable upload', function () { + runSuite('resumable') + }) + + describe('Legacy upload', function () { + runSuite('legacy') + }) + }) + + describe('When updating a video', function () { + const baseCorrectParams = { + name: 'my super name', + category: 5, + licence: 2, + language: 'pt', + nsfw: false, + commentsEnabled: false, + downloadEnabled: false, + description: 'my super description', + privacy: VideoPrivacy.PUBLIC, + tags: [ 'tag1', 'tag2' ] + } + + before(async function () { + const { data } = await server.videos.list() + video = data[0] + }) + + it('Should fail with nothing', async function () { + const fields = {} + await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail without a valid uuid', async function () { + const fields = baseCorrectParams + await makePutBodyRequest({ url: server.url, path: path + 'blabla', token: server.accessToken, fields }) + }) + + it('Should fail with an unknown id', async function () { + const fields = baseCorrectParams + + await makePutBodyRequest({ + url: server.url, + path: path + '4da6fde3-88f7-4d16-b119-108df5630b06', + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a long name', async function () { + const fields = { ...baseCorrectParams, name: 'super'.repeat(65) } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a bad category', async function () { + const fields = { ...baseCorrectParams, category: 125 } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a bad licence', async function () { + const fields = { ...baseCorrectParams, licence: 125 } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a bad language', async function () { + const fields = { ...baseCorrectParams, language: 'a'.repeat(15) } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a long description', async function () { + const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a long support text', async function () { + const fields = { ...baseCorrectParams, support: 'super'.repeat(201) } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a bad channel', async function () { + const fields = { ...baseCorrectParams, channelId: 545454 } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with too many tags', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a tag length too low', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 't' ] } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a tag length too big', async function () { + const fields = { ...baseCorrectParams, tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a bad schedule update (miss updateAt)', async function () { + const fields = { ...baseCorrectParams, scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a bad schedule update (wrong updateAt)', async function () { + const fields = { ...baseCorrectParams, scheduleUpdate: { updateAt: 'toto', privacy: VideoPrivacy.PUBLIC } } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with a bad originally published at param', async function () { + const fields = { ...baseCorrectParams, originallyPublishedAt: 'toto' } + + await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + }) + + it('Should fail with an incorrect thumbnail file', async function () { + const fields = baseCorrectParams + const attaches = { + thumbnailfile: buildAbsoluteFixturePath('video_short.mp4') + } + + await makeUploadRequest({ + url: server.url, + method: 'PUT', + path: path + video.shortUUID, + token: server.accessToken, + fields, + attaches + }) + }) + + it('Should fail with a big thumbnail file', async function () { + const fields = baseCorrectParams + const attaches = { + thumbnailfile: buildAbsoluteFixturePath('custom-preview-big.png') + } + + await makeUploadRequest({ + url: server.url, + method: 'PUT', + path: path + video.shortUUID, + token: server.accessToken, + fields, + attaches + }) + }) + + it('Should fail with an incorrect preview file', async function () { + const fields = baseCorrectParams + const attaches = { + previewfile: buildAbsoluteFixturePath('video_short.mp4') + } + + await makeUploadRequest({ + url: server.url, + method: 'PUT', + path: path + video.shortUUID, + token: server.accessToken, + fields, + attaches + }) + }) + + it('Should fail with a big preview file', async function () { + const fields = baseCorrectParams + const attaches = { + previewfile: buildAbsoluteFixturePath('custom-preview-big.png') + } + + await makeUploadRequest({ + url: server.url, + method: 'PUT', + path: path + video.shortUUID, + token: server.accessToken, + fields, + attaches + }) + }) + + it('Should fail with a video of another user without the appropriate right', async function () { + const fields = baseCorrectParams + + await makePutBodyRequest({ + url: server.url, + path: path + video.shortUUID, + token: userAccessToken, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a video of another server') + + it('Shoud report the appropriate error', async function () { + const fields = { ...baseCorrectParams, licence: 125 } + + const res = await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields }) + const error = res.body as PeerTubeProblemDocument + + expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/putVideo') + + expect(error.type).to.equal('about:blank') + expect(error.title).to.equal('Bad Request') + + expect(error.detail).to.equal('Incorrect request parameters: licence') + expect(error.error).to.equal('Incorrect request parameters: licence') + + expect(error.status).to.equal(HttpStatusCode.BAD_REQUEST_400) + expect(error['invalid-params'].licence).to.exist + }) + + it('Should succeed with the correct parameters', async function () { + const fields = baseCorrectParams + + await makePutBodyRequest({ + url: server.url, + path: path + video.shortUUID, + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When getting a video', function () { + it('Should return the list of the videos with nothing', async function () { + const res = await makeGetRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.data).to.be.an('array') + expect(res.body.data.length).to.equal(6) + }) + + it('Should fail without a correct uuid', async function () { + await server.videos.get({ id: 'coucou', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should return 404 with an incorrect video', async function () { + await server.videos.get({ id: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Shoud report the appropriate error', async function () { + const body = await server.videos.get({ id: 'hi', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + const error = body as unknown as PeerTubeProblemDocument + + expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/getVideo') + + expect(error.type).to.equal('about:blank') + expect(error.title).to.equal('Bad Request') + + expect(error.detail).to.equal('Incorrect request parameters: id') + expect(error.error).to.equal('Incorrect request parameters: id') + + expect(error.status).to.equal(HttpStatusCode.BAD_REQUEST_400) + expect(error['invalid-params'].id).to.exist + }) + + it('Should succeed with the correct parameters', async function () { + await server.videos.get({ id: video.shortUUID }) + }) + }) + + describe('When rating a video', function () { + let videoId: number + + before(async function () { + const { data } = await server.videos.list() + videoId = data[0].id + }) + + it('Should fail without a valid uuid', async function () { + const fields = { + rating: 'like' + } + await makePutBodyRequest({ url: server.url, path: path + 'blabla/rate', token: server.accessToken, fields }) + }) + + it('Should fail with an unknown id', async function () { + const fields = { + rating: 'like' + } + await makePutBodyRequest({ + url: server.url, + path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/rate', + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a wrong rating', async function () { + const fields = { + rating: 'likes' + } + await makePutBodyRequest({ url: server.url, path: path + videoId + '/rate', token: server.accessToken, fields }) + }) + + it('Should fail with a private video of another user', async function () { + const fields = { + rating: 'like' + } + await makePutBodyRequest({ + url: server.url, + path: path + privateVideo.uuid + '/rate', + token: userAccessToken, + fields, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct parameters', async function () { + const fields = { + rating: 'like' + } + await makePutBodyRequest({ + url: server.url, + path: path + videoId + '/rate', + token: server.accessToken, + fields, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When removing a video', function () { + it('Should have 404 with nothing', async function () { + await makeDeleteRequest({ + url: server.url, + path, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail without a correct uuid', async function () { + await server.videos.remove({ id: 'hello', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with a video which does not exist', async function () { + await server.videos.remove({ id: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail with a video of another user without the appropriate right', async function () { + await server.videos.remove({ token: userAccessToken, id: video.uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail with a video of another server') + + it('Shoud report the appropriate error', async function () { + const body = await server.videos.remove({ id: 'hello', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + const error = body as PeerTubeProblemDocument + + expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/delVideo') + + expect(error.type).to.equal('about:blank') + expect(error.title).to.equal('Bad Request') + + expect(error.detail).to.equal('Incorrect request parameters: id') + expect(error.error).to.equal('Incorrect request parameters: id') + + expect(error.status).to.equal(HttpStatusCode.BAD_REQUEST_400) + expect(error['invalid-params'].id).to.exist + }) + + it('Should succeed with the correct parameters', async function () { + await server.videos.remove({ id: video.uuid }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/check-params/views.ts b/packages/tests/src/api/check-params/views.ts new file mode 100644 index 000000000..c454d4b80 --- /dev/null +++ b/packages/tests/src/api/check-params/views.ts @@ -0,0 +1,227 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test videos views', function () { + let servers: PeerTubeServer[] + let liveVideoId: string + let videoId: string + let remoteVideoId: string + let userAccessToken: string + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await servers[0].config.enableLive({ allowReplay: false, transcoding: false }); + + ({ uuid: videoId } = await servers[0].videos.quickUpload({ name: 'video' })); + ({ uuid: remoteVideoId } = await servers[1].videos.quickUpload({ name: 'video' })); + ({ uuid: liveVideoId } = await servers[0].live.create({ + fields: { + name: 'live', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[0].store.channel.id + } + })) + + userAccessToken = await servers[0].users.generateUserAndToken('user') + + await doubleFollow(servers[0], servers[1]) + }) + + describe('When viewing a video', async function () { + + it('Should fail without current time', async function () { + await servers[0].views.view({ id: videoId, currentTime: undefined, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an invalid current time', async function () { + await servers[0].views.view({ id: videoId, currentTime: -1, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await servers[0].views.view({ id: videoId, currentTime: 10, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with correct parameters', async function () { + await servers[0].views.view({ id: videoId, currentTime: 1 }) + }) + }) + + describe('When getting overall stats', function () { + + it('Should fail with a remote video', async function () { + await servers[0].videoStats.getOverallStats({ videoId: remoteVideoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should fail without token', async function () { + await servers[0].videoStats.getOverallStats({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with another token', async function () { + await servers[0].videoStats.getOverallStats({ + videoId, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid start date', async function () { + await servers[0].videoStats.getOverallStats({ + videoId, + startDate: 'fake' as any, + endDate: new Date().toISOString(), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an invalid end date', async function () { + await servers[0].videoStats.getOverallStats({ + videoId, + startDate: new Date().toISOString(), + endDate: 'fake' as any, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await servers[0].videoStats.getOverallStats({ + videoId, + startDate: new Date().toISOString(), + endDate: new Date().toISOString() + }) + }) + }) + + describe('When getting timeserie stats', function () { + + it('Should fail with a remote video', async function () { + await servers[0].videoStats.getTimeserieStats({ + videoId: remoteVideoId, + metric: 'viewers', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail without token', async function () { + await servers[0].videoStats.getTimeserieStats({ + videoId, + token: null, + metric: 'viewers', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with another token', async function () { + await servers[0].videoStats.getTimeserieStats({ + videoId, + token: userAccessToken, + metric: 'viewers', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid metric', async function () { + await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'hello' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an invalid start date', async function () { + await servers[0].videoStats.getTimeserieStats({ + videoId, + metric: 'viewers', + startDate: 'fake' as any, + endDate: new Date(), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with an invalid end date', async function () { + await servers[0].videoStats.getTimeserieStats({ + videoId, + metric: 'viewers', + startDate: new Date(), + endDate: 'fake' as any, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail if start date is specified but not end date', async function () { + await servers[0].videoStats.getTimeserieStats({ + videoId, + metric: 'viewers', + startDate: new Date(), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail if end date is specified but not start date', async function () { + await servers[0].videoStats.getTimeserieStats({ + videoId, + metric: 'viewers', + endDate: new Date(), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a too big interval', async function () { + await servers[0].videoStats.getTimeserieStats({ + videoId, + metric: 'viewers', + startDate: new Date('2000-04-07T08:31:57.126Z'), + endDate: new Date(), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'viewers' }) + }) + }) + + describe('When getting retention stats', function () { + + it('Should fail with a remote video', async function () { + await servers[0].videoStats.getRetentionStats({ + videoId: remoteVideoId, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail without token', async function () { + await servers[0].videoStats.getRetentionStats({ + videoId, + token: null, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with another token', async function () { + await servers[0].videoStats.getRetentionStats({ + videoId, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail on live video', async function () { + await servers[0].videoStats.getRetentionStats({ videoId: liveVideoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should succeed with the correct parameters', async function () { + await servers[0].videoStats.getRetentionStats({ videoId }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/live/index.ts b/packages/tests/src/api/live/index.ts new file mode 100644 index 000000000..e61e6c611 --- /dev/null +++ b/packages/tests/src/api/live/index.ts @@ -0,0 +1,7 @@ +import './live-constraints.js' +import './live-fast-restream.js' +import './live-socket-messages.js' +import './live-permanent.js' +import './live-rtmps.js' +import './live-save-replay.js' +import './live.js' diff --git a/packages/tests/src/api/live/live-constraints.ts b/packages/tests/src/api/live/live-constraints.ts new file mode 100644 index 000000000..f62994cbd --- /dev/null +++ b/packages/tests/src/api/live/live-constraints.ts @@ -0,0 +1,237 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { LiveVideoError, UserVideoQuota, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + ConfigCommand, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs, + waitUntilLiveReplacedByReplayOnAllServers, + waitUntilLiveWaitingOnAllServers +} from '@peertube/peertube-server-commands' +import { checkLiveCleanup } from '../../shared/live.js' + +describe('Test live constraints', function () { + let servers: PeerTubeServer[] = [] + let userId: number + let userAccessToken: string + let userChannelId: number + + async function createLiveWrapper (options: { replay: boolean, permanent: boolean }) { + const { replay, permanent } = options + + const liveAttributes = { + name: 'user live', + channelId: userChannelId, + privacy: VideoPrivacy.PUBLIC, + saveReplay: replay, + replaySettings: options.replay ? { privacy: VideoPrivacy.PUBLIC } : undefined, + permanentLive: permanent + } + + const { uuid } = await servers[0].live.create({ token: userAccessToken, fields: liveAttributes }) + return uuid + } + + async function checkSaveReplay (videoId: string, resolutions = [ 720 ]) { + for (const server of servers) { + const video = await server.videos.get({ id: videoId }) + expect(video.isLive).to.be.false + expect(video.duration).to.be.greaterThan(0) + } + + await checkLiveCleanup({ server: servers[0], permanent: false, videoUUID: videoId, savedResolutions: resolutions }) + } + + function updateQuota (options: { total: number, daily: number }) { + return servers[0].users.update({ + userId, + videoQuota: options.total, + videoQuotaDaily: options.daily + }) + } + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + transcoding: { + enabled: false + } + } + } + }) + + { + const res = await servers[0].users.generate('user1') + userId = res.userId + userChannelId = res.userChannelId + userAccessToken = res.token + + await updateQuota({ total: 1, daily: -1 }) + } + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + it('Should not have size limit if save replay is disabled', async function () { + this.timeout(60000) + + const userVideoLiveoId = await createLiveWrapper({ replay: false, permanent: false }) + await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: false }) + }) + + it('Should have size limit depending on user global quota if save replay is enabled on non permanent live', async function () { + this.timeout(60000) + + // Wait for user quota memoize cache invalidation + await wait(5000) + + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false }) + await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) + + await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId) + await waitJobs(servers) + + await checkSaveReplay(userVideoLiveoId) + + const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) + expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) + }) + + it('Should have size limit depending on user global quota if save replay is enabled on a permanent live', async function () { + this.timeout(60000) + + // Wait for user quota memoize cache invalidation + await wait(5000) + + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: true }) + await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) + + await waitJobs(servers) + await waitUntilLiveWaitingOnAllServers(servers, userVideoLiveoId) + + const session = await servers[0].live.findLatestSession({ videoId: userVideoLiveoId }) + expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) + }) + + it('Should have size limit depending on user daily quota if save replay is enabled', async function () { + this.timeout(60000) + + // Wait for user quota memoize cache invalidation + await wait(5000) + + await updateQuota({ total: -1, daily: 1 }) + + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false }) + await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) + + await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId) + await waitJobs(servers) + + await checkSaveReplay(userVideoLiveoId) + + const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) + expect(session.error).to.equal(LiveVideoError.QUOTA_EXCEEDED) + }) + + it('Should succeed without quota limit', async function () { + this.timeout(60000) + + // Wait for user quota memoize cache invalidation + await wait(5000) + + await updateQuota({ total: 10 * 1000 * 1000, daily: -1 }) + + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false }) + await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: false }) + }) + + it('Should have the same quota in admin and as a user', async function () { + this.timeout(120000) + + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false }) + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ token: userAccessToken, videoId: userVideoLiveoId }) + + await servers[0].live.waitUntilPublished({ videoId: userVideoLiveoId }) + // Wait previous live cleanups + await wait(3000) + + const baseQuota = await servers[0].users.getMyQuotaUsed({ token: userAccessToken }) + + let quotaUser: UserVideoQuota + + do { + await wait(500) + + quotaUser = await servers[0].users.getMyQuotaUsed({ token: userAccessToken }) + } while (quotaUser.videoQuotaUsed <= baseQuota.videoQuotaUsed) + + const { data } = await servers[0].users.list() + const quotaAdmin = data.find(u => u.username === 'user1') + + expect(quotaUser.videoQuotaUsed).to.be.above(baseQuota.videoQuotaUsed) + expect(quotaUser.videoQuotaUsedDaily).to.be.above(baseQuota.videoQuotaUsedDaily) + + expect(quotaAdmin.videoQuotaUsed).to.be.above(baseQuota.videoQuotaUsed) + expect(quotaAdmin.videoQuotaUsedDaily).to.be.above(baseQuota.videoQuotaUsedDaily) + + expect(quotaUser.videoQuotaUsed).to.be.above(10) + expect(quotaUser.videoQuotaUsedDaily).to.be.above(10) + expect(quotaAdmin.videoQuotaUsed).to.be.above(10) + expect(quotaAdmin.videoQuotaUsedDaily).to.be.above(10) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should have max duration limit', async function () { + this.timeout(240000) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + maxDuration: 15, + transcoding: { + enabled: true, + resolutions: ConfigCommand.getCustomConfigResolutions(true) + } + } + } + }) + + const userVideoLiveoId = await createLiveWrapper({ replay: true, permanent: false }) + await servers[0].live.runAndTestStreamError({ token: userAccessToken, videoId: userVideoLiveoId, shouldHaveError: true }) + + await waitUntilLiveReplacedByReplayOnAllServers(servers, userVideoLiveoId) + await waitJobs(servers) + + await checkSaveReplay(userVideoLiveoId, [ 720, 480, 360, 240, 144 ]) + + const session = await servers[0].live.getReplaySession({ videoId: userVideoLiveoId }) + expect(session.error).to.equal(LiveVideoError.DURATION_EXCEEDED) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/live/live-fast-restream.ts b/packages/tests/src/api/live/live-fast-restream.ts new file mode 100644 index 000000000..d34b00cbe --- /dev/null +++ b/packages/tests/src/api/live/live-fast-restream.ts @@ -0,0 +1,153 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { LiveVideoCreate, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Fast restream in live', function () { + let server: PeerTubeServer + + async function createLiveWrapper (options: { permanent: boolean, replay: boolean }) { + const attributes: LiveVideoCreate = { + channelId: server.store.channel.id, + privacy: VideoPrivacy.PUBLIC, + name: 'my super live', + saveReplay: options.replay, + replaySettings: options.replay ? { privacy: VideoPrivacy.PUBLIC } : undefined, + permanentLive: options.permanent + } + + const { uuid } = await server.live.create({ fields: attributes }) + return uuid + } + + async function fastRestreamWrapper ({ replay }: { replay: boolean }) { + const liveVideoUUID = await createLiveWrapper({ permanent: true, replay }) + await waitJobs([ server ]) + + const rtmpOptions = { + videoId: liveVideoUUID, + copyCodecs: true, + fixtureName: 'video_short.mp4' + } + + // Streaming session #1 + let ffmpegCommand = await server.live.sendRTMPStreamInVideo(rtmpOptions) + await server.live.waitUntilPublished({ videoId: liveVideoUUID }) + + const video = await server.videos.get({ id: liveVideoUUID }) + const session1PlaylistId = video.streamingPlaylists[0].id + + await stopFfmpeg(ffmpegCommand) + await server.live.waitUntilWaiting({ videoId: liveVideoUUID }) + + // Streaming session #2 + ffmpegCommand = await server.live.sendRTMPStreamInVideo(rtmpOptions) + + let hasNewPlaylist = false + do { + const video = await server.videos.get({ id: liveVideoUUID }) + hasNewPlaylist = video.streamingPlaylists.length === 1 && video.streamingPlaylists[0].id !== session1PlaylistId + + await wait(100) + } while (!hasNewPlaylist) + + await server.live.waitUntilSegmentGeneration({ + server, + videoUUID: liveVideoUUID, + segment: 1, + playlistNumber: 0 + }) + + return { ffmpegCommand, liveVideoUUID } + } + + async function ensureLastLiveWorks (liveId: string) { + // Equivalent to PEERTUBE_TEST_CONSTANTS_VIDEO_LIVE_CLEANUP_DELAY + for (let i = 0; i < 100; i++) { + const video = await server.videos.get({ id: liveId }) + expect(video.streamingPlaylists).to.have.lengthOf(1) + + try { + await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 }) + await server.streamingPlaylists.get({ url: video.streamingPlaylists[0].playlistUrl }) + await server.streamingPlaylists.getSegmentSha256({ url: video.streamingPlaylists[0].segmentsSha256Url }) + } catch (err) { + // FIXME: try to debug error in CI "Unexpected end of JSON input" + console.error(err) + throw err + } + + await wait(100) + } + } + + async function runTest (replay: boolean) { + const { ffmpegCommand, liveVideoUUID } = await fastRestreamWrapper({ replay }) + + // TODO: remove, we try to debug a test timeout failure here + console.log('Ensuring last live works') + + await ensureLastLiveWorks(liveVideoUUID) + + await stopFfmpeg(ffmpegCommand) + await server.live.waitUntilWaiting({ videoId: liveVideoUUID }) + + // Wait for replays + await waitJobs([ server ]) + + const { total, data: sessions } = await server.live.listSessions({ videoId: liveVideoUUID }) + + expect(total).to.equal(2) + expect(sessions).to.have.lengthOf(2) + + for (const session of sessions) { + expect(session.error).to.be.null + + if (replay) { + expect(session.replayVideo).to.exist + + await server.videos.get({ id: session.replayVideo.uuid }) + } else { + expect(session.replayVideo).to.not.exist + } + } + } + + before(async function () { + this.timeout(120000) + + const env = { PEERTUBE_TEST_CONSTANTS_VIDEO_LIVE_CLEANUP_DELAY: '10000' } + server = await createSingleServer(1, {}, { env }) + + // Get the access tokens + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableMinimumTranscoding({ webVideo: false, hls: true }) + await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' }) + }) + + it('Should correctly fast restream in a permanent live with and without save replay', async function () { + this.timeout(480000) + + // A test can take a long time, so prefer to run them in parallel + await Promise.all([ + runTest(true), + runTest(false) + ]) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/live/live-permanent.ts b/packages/tests/src/api/live/live-permanent.ts new file mode 100644 index 000000000..4ffcc7ed4 --- /dev/null +++ b/packages/tests/src/api/live/live-permanent.ts @@ -0,0 +1,204 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { LiveVideoCreate, VideoPrivacy, VideoState, VideoStateType } from '@peertube/peertube-models' +import { checkLiveCleanup } from '@tests/shared/live.js' +import { + cleanupTests, + ConfigCommand, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Permanent live', function () { + let servers: PeerTubeServer[] = [] + let videoUUID: string + + async function createLiveWrapper (permanentLive: boolean) { + const attributes: LiveVideoCreate = { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + name: 'my super live', + saveReplay: false, + permanentLive + } + + const { uuid } = await servers[0].live.create({ fields: attributes }) + return uuid + } + + async function checkVideoState (videoId: string, state: VideoStateType) { + for (const server of servers) { + const video = await server.videos.get({ id: videoId }) + expect(video.state.id).to.equal(state) + } + } + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + maxDuration: -1, + transcoding: { + enabled: true, + resolutions: ConfigCommand.getCustomConfigResolutions(true) + } + } + } + }) + }) + + it('Should create a non permanent live and update it to be a permanent live', async function () { + this.timeout(20000) + + const videoUUID = await createLiveWrapper(false) + + { + const live = await servers[0].live.get({ videoId: videoUUID }) + expect(live.permanentLive).to.be.false + } + + await servers[0].live.update({ videoId: videoUUID, fields: { permanentLive: true } }) + + { + const live = await servers[0].live.get({ videoId: videoUUID }) + expect(live.permanentLive).to.be.true + } + }) + + it('Should create a permanent live', async function () { + this.timeout(20000) + + videoUUID = await createLiveWrapper(true) + + const live = await servers[0].live.get({ videoId: videoUUID }) + expect(live.permanentLive).to.be.true + + await waitJobs(servers) + }) + + it('Should stream into this permanent live', async function () { + this.timeout(240_000) + + const beforePublication = new Date() + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: videoUUID }) + + for (const server of servers) { + await server.live.waitUntilPublished({ videoId: videoUUID }) + } + + await checkVideoState(videoUUID, VideoState.PUBLISHED) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(new Date(video.publishedAt)).greaterThan(beforePublication) + } + + await stopFfmpeg(ffmpegCommand) + await servers[0].live.waitUntilWaiting({ videoId: videoUUID }) + + await waitJobs(servers) + }) + + it('Should have cleaned up this live', async function () { + this.timeout(40000) + + await wait(5000) + await waitJobs(servers) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) + + expect(videoDetails.streamingPlaylists).to.have.lengthOf(0) + } + + await checkLiveCleanup({ server: servers[0], permanent: true, videoUUID }) + }) + + it('Should have set this live to waiting for live state', async function () { + this.timeout(20000) + + await checkVideoState(videoUUID, VideoState.WAITING_FOR_LIVE) + }) + + it('Should be able to stream again in the permanent live', async function () { + this.timeout(60000) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + maxDuration: -1, + transcoding: { + enabled: true, + resolutions: ConfigCommand.getCustomConfigResolutions(false) + } + } + } + }) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: videoUUID }) + + for (const server of servers) { + await server.live.waitUntilPublished({ videoId: videoUUID }) + } + + await checkVideoState(videoUUID, VideoState.PUBLISHED) + + const count = await servers[0].live.countPlaylists({ videoUUID }) + // master playlist and 720p playlist + expect(count).to.equal(2) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should have appropriate sessions', async function () { + this.timeout(60000) + + await servers[0].live.waitUntilWaiting({ videoId: videoUUID }) + + const { data, total } = await servers[0].live.listSessions({ videoId: videoUUID }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + for (const session of data) { + expect(session.startDate).to.exist + expect(session.endDate).to.exist + + expect(session.error).to.not.exist + } + }) + + it('Should remove the live and have cleaned up the directory', async function () { + this.timeout(60000) + + await servers[0].videos.remove({ id: videoUUID }) + await waitJobs(servers) + + await checkLiveCleanup({ server: servers[0], permanent: true, videoUUID }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/live/live-rtmps.ts b/packages/tests/src/api/live/live-rtmps.ts new file mode 100644 index 000000000..4ab59ed4c --- /dev/null +++ b/packages/tests/src/api/live/live-rtmps.ts @@ -0,0 +1,143 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + testFfmpegStreamError, + waitUntilLivePublishedOnAllServers +} from '@peertube/peertube-server-commands' + +describe('Test live RTMPS', function () { + let server: PeerTubeServer + let rtmpUrl: string + let rtmpsUrl: string + + async function createLiveWrapper () { + const liveAttributes = { + name: 'live', + channelId: server.store.channel.id, + privacy: VideoPrivacy.PUBLIC, + saveReplay: false + } + + const { uuid } = await server.live.create({ fields: liveAttributes }) + + const live = await server.live.get({ videoId: uuid }) + const video = await server.videos.get({ id: uuid }) + + return Object.assign(video, live) + } + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + // Get the access tokens + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + transcoding: { + enabled: false + } + } + } + }) + + rtmpUrl = 'rtmp://' + server.hostname + ':' + server.rtmpPort + '/live' + rtmpsUrl = 'rtmps://' + server.hostname + ':' + server.rtmpsPort + '/live' + }) + + it('Should enable RTMPS endpoint only', async function () { + this.timeout(240000) + + await server.kill() + await server.run({ + live: { + rtmp: { + enabled: false + }, + rtmps: { + enabled: true, + port: server.rtmpsPort, + key_file: buildAbsoluteFixturePath('rtmps.key'), + cert_file: buildAbsoluteFixturePath('rtmps.cert') + } + } + }) + + { + const liveVideo = await createLiveWrapper() + + expect(liveVideo.rtmpUrl).to.not.exist + expect(liveVideo.rtmpsUrl).to.equal(rtmpsUrl) + + const command = sendRTMPStream({ rtmpBaseUrl: rtmpUrl, streamKey: liveVideo.streamKey }) + await testFfmpegStreamError(command, true) + } + + { + const liveVideo = await createLiveWrapper() + + const command = sendRTMPStream({ rtmpBaseUrl: rtmpsUrl, streamKey: liveVideo.streamKey }) + await waitUntilLivePublishedOnAllServers([ server ], liveVideo.uuid) + await stopFfmpeg(command) + } + }) + + it('Should enable both RTMP and RTMPS', async function () { + this.timeout(240000) + + await server.kill() + await server.run({ + live: { + rtmp: { + enabled: true, + port: server.rtmpPort + }, + rtmps: { + enabled: true, + port: server.rtmpsPort, + key_file: buildAbsoluteFixturePath('rtmps.key'), + cert_file: buildAbsoluteFixturePath('rtmps.cert') + } + } + }) + + { + const liveVideo = await createLiveWrapper() + + expect(liveVideo.rtmpUrl).to.equal(rtmpUrl) + expect(liveVideo.rtmpsUrl).to.equal(rtmpsUrl) + + const command = sendRTMPStream({ rtmpBaseUrl: rtmpUrl, streamKey: liveVideo.streamKey }) + await waitUntilLivePublishedOnAllServers([ server ], liveVideo.uuid) + await stopFfmpeg(command) + } + + { + const liveVideo = await createLiveWrapper() + + const command = sendRTMPStream({ rtmpBaseUrl: rtmpsUrl, streamKey: liveVideo.streamKey }) + await waitUntilLivePublishedOnAllServers([ server ], liveVideo.uuid) + await stopFfmpeg(command) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/live/live-save-replay.ts b/packages/tests/src/api/live/live-save-replay.ts new file mode 100644 index 000000000..84135365b --- /dev/null +++ b/packages/tests/src/api/live/live-save-replay.ts @@ -0,0 +1,583 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { FfmpegCommand } from 'fluent-ffmpeg' +import { wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + HttpStatusCodeType, + LiveVideoCreate, + LiveVideoError, + VideoPrivacy, + VideoPrivacyType, + VideoState, + VideoStateType +} from '@peertube/peertube-models' +import { checkLiveCleanup } from '@tests/shared/live.js' +import { + cleanupTests, + ConfigCommand, + createMultipleServers, + doubleFollow, + findExternalSavedVideo, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + testFfmpegStreamError, + waitJobs, + waitUntilLivePublishedOnAllServers, + waitUntilLiveReplacedByReplayOnAllServers, + waitUntilLiveWaitingOnAllServers +} from '@peertube/peertube-server-commands' + +describe('Save replay setting', function () { + let servers: PeerTubeServer[] = [] + let liveVideoUUID: string + let ffmpegCommand: FfmpegCommand + + async function createLiveWrapper (options: { permanent: boolean, replay: boolean, replaySettings?: { privacy: VideoPrivacyType } }) { + if (liveVideoUUID) { + try { + await servers[0].videos.remove({ id: liveVideoUUID }) + await waitJobs(servers) + } catch {} + } + + const attributes: LiveVideoCreate = { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + name: 'live'.repeat(30), + saveReplay: options.replay, + replaySettings: options.replaySettings, + permanentLive: options.permanent + } + + const { uuid } = await servers[0].live.create({ fields: attributes }) + return uuid + } + + async function publishLive (options: { permanent: boolean, replay: boolean, replaySettings?: { privacy: VideoPrivacyType } }) { + liveVideoUUID = await createLiveWrapper(options) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + + const liveDetails = await servers[0].videos.get({ id: liveVideoUUID }) + + await waitJobs(servers) + await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + + return { ffmpegCommand, liveDetails } + } + + async function publishLiveAndDelete (options: { permanent: boolean, replay: boolean, replaySettings?: { privacy: VideoPrivacyType } }) { + const { ffmpegCommand, liveDetails } = await publishLive(options) + + await Promise.all([ + servers[0].videos.remove({ id: liveVideoUUID }), + testFfmpegStreamError(ffmpegCommand, true) + ]) + + await waitJobs(servers) + await wait(5000) + await waitJobs(servers) + + return { liveDetails } + } + + async function publishLiveAndBlacklist (options: { + permanent: boolean + replay: boolean + replaySettings?: { privacy: VideoPrivacyType } + }) { + const { ffmpegCommand, liveDetails } = await publishLive(options) + + await Promise.all([ + servers[0].blacklist.add({ videoId: liveVideoUUID, reason: 'bad live', unfederate: true }), + testFfmpegStreamError(ffmpegCommand, true) + ]) + + await waitJobs(servers) + await wait(5000) + await waitJobs(servers) + + return { liveDetails } + } + + async function checkVideosExist (videoId: string, existsInList: boolean, expectedStatus?: HttpStatusCodeType) { + for (const server of servers) { + const length = existsInList ? 1 : 0 + + const { data, total } = await server.videos.list() + expect(data).to.have.lengthOf(length) + expect(total).to.equal(length) + + if (expectedStatus) { + await server.videos.get({ id: videoId, expectedStatus }) + } + } + } + + async function checkVideoState (videoId: string, state: VideoStateType) { + for (const server of servers) { + const video = await server.videos.get({ id: videoId }) + expect(video.state.id).to.equal(state) + } + } + + async function checkVideoPrivacy (videoId: string, privacy: VideoPrivacyType) { + for (const server of servers) { + const video = await server.videos.get({ id: videoId }) + expect(video.privacy.id).to.equal(privacy) + } + } + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + maxDuration: -1, + transcoding: { + enabled: false, + resolutions: ConfigCommand.getCustomConfigResolutions(true) + } + } + } + }) + }) + + describe('With save replay disabled', function () { + let sessionStartDateMin: Date + let sessionStartDateMax: Date + let sessionEndDateMin: Date + + it('Should correctly create and federate the "waiting for stream" live', async function () { + this.timeout(40000) + + liveVideoUUID = await createLiveWrapper({ permanent: false, replay: false }) + + await waitJobs(servers) + + await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE) + }) + + it('Should correctly have updated the live and federated it when streaming in the live', async function () { + this.timeout(120000) + + ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + + sessionStartDateMin = new Date() + await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + sessionStartDateMax = new Date() + + await waitJobs(servers) + + await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) + }) + + it('Should correctly delete the video files after the stream ended', async function () { + this.timeout(120000) + + sessionEndDateMin = new Date() + await stopFfmpeg(ffmpegCommand) + + for (const server of servers) { + await server.live.waitUntilEnded({ videoId: liveVideoUUID }) + } + await waitJobs(servers) + + // Live still exist, but cannot be played anymore + await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideoState(liveVideoUUID, VideoState.LIVE_ENDED) + + // No resolutions saved since we did not save replay + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) + }) + + it('Should have appropriate ended session', async function () { + const { data, total } = await servers[0].live.listSessions({ videoId: liveVideoUUID }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const session = data[0] + + const startDate = new Date(session.startDate) + expect(startDate).to.be.above(sessionStartDateMin) + expect(startDate).to.be.below(sessionStartDateMax) + + expect(session.endDate).to.exist + expect(new Date(session.endDate)).to.be.above(sessionEndDateMin) + + expect(session.saveReplay).to.be.false + expect(session.error).to.not.exist + expect(session.replayVideo).to.not.exist + }) + + it('Should correctly terminate the stream on blacklist and delete the live', async function () { + this.timeout(120000) + + await publishLiveAndBlacklist({ permanent: false, replay: false }) + + await checkVideosExist(liveVideoUUID, false) + + await servers[0].videos.get({ id: liveVideoUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await servers[1].videos.get({ id: liveVideoUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + + await wait(5000) + await waitJobs(servers) + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) + }) + + it('Should have blacklisted session error', async function () { + const session = await servers[0].live.findLatestSession({ videoId: liveVideoUUID }) + expect(session.startDate).to.exist + expect(session.endDate).to.exist + + expect(session.error).to.equal(LiveVideoError.BLACKLISTED) + expect(session.replayVideo).to.not.exist + }) + + it('Should correctly terminate the stream on delete and delete the video', async function () { + this.timeout(120000) + + await publishLiveAndDelete({ permanent: false, replay: false }) + + await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404) + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) + }) + }) + + describe('With save replay enabled on non permanent live', function () { + + it('Should correctly create and federate the "waiting for stream" live', async function () { + this.timeout(120000) + + liveVideoUUID = await createLiveWrapper({ permanent: false, replay: true, replaySettings: { privacy: VideoPrivacy.UNLISTED } }) + + await waitJobs(servers) + + await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE) + await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) + }) + + it('Should correctly have updated the live and federated it when streaming in the live', async function () { + this.timeout(120000) + + ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + + await waitJobs(servers) + + await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) + await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) + }) + + it('Should correctly have saved the live and federated it after the streaming', async function () { + this.timeout(120000) + + const session = await servers[0].live.findLatestSession({ videoId: liveVideoUUID }) + expect(session.endDate).to.not.exist + expect(session.endingProcessed).to.be.false + expect(session.saveReplay).to.be.true + expect(session.replaySettings).to.exist + expect(session.replaySettings.privacy).to.equal(VideoPrivacy.UNLISTED) + + await stopFfmpeg(ffmpegCommand) + + await waitUntilLiveReplacedByReplayOnAllServers(servers, liveVideoUUID) + await waitJobs(servers) + + // Live has been transcoded + await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) + await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.UNLISTED) + }) + + it('Should find the replay live session', async function () { + const session = await servers[0].live.getReplaySession({ videoId: liveVideoUUID }) + + expect(session).to.exist + + expect(session.startDate).to.exist + expect(session.endDate).to.exist + + expect(session.error).to.not.exist + expect(session.saveReplay).to.be.true + expect(session.endingProcessed).to.be.true + expect(session.replaySettings).to.exist + expect(session.replaySettings.privacy).to.equal(VideoPrivacy.UNLISTED) + + expect(session.replayVideo).to.exist + expect(session.replayVideo.id).to.exist + expect(session.replayVideo.shortUUID).to.exist + expect(session.replayVideo.uuid).to.equal(liveVideoUUID) + }) + + it('Should update the saved live and correctly federate the updated attributes', async function () { + this.timeout(120000) + + await servers[0].videos.update({ id: liveVideoUUID, attributes: { name: 'video updated', privacy: VideoPrivacy.PUBLIC } }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: liveVideoUUID }) + expect(video.name).to.equal('video updated') + expect(video.isLive).to.be.false + expect(video.privacy.id).to.equal(VideoPrivacy.PUBLIC) + } + }) + + it('Should have cleaned up the live files', async function () { + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false, savedResolutions: [ 720 ] }) + }) + + it('Should correctly terminate the stream on blacklist and blacklist the saved replay video', async function () { + this.timeout(120000) + + await publishLiveAndBlacklist({ permanent: false, replay: true, replaySettings: { privacy: VideoPrivacy.PUBLIC } }) + + await checkVideosExist(liveVideoUUID, false) + + await servers[0].videos.get({ id: liveVideoUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await servers[1].videos.get({ id: liveVideoUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + + await wait(5000) + await waitJobs(servers) + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false, savedResolutions: [ 720 ] }) + }) + + it('Should correctly terminate the stream on delete and delete the video', async function () { + this.timeout(120000) + + await publishLiveAndDelete({ permanent: false, replay: true, replaySettings: { privacy: VideoPrivacy.PUBLIC } }) + + await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404) + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) + }) + }) + + describe('With save replay enabled on permanent live', function () { + let lastReplayUUID: string + + describe('With a first live and its replay', function () { + + it('Should correctly create and federate the "waiting for stream" live', async function () { + this.timeout(120000) + + liveVideoUUID = await createLiveWrapper({ permanent: true, replay: true, replaySettings: { privacy: VideoPrivacy.UNLISTED } }) + + await waitJobs(servers) + + await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE) + await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) + }) + + it('Should correctly have updated the live and federated it when streaming in the live', async function () { + this.timeout(120000) + + ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + + await waitJobs(servers) + + await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) + await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) + }) + + it('Should correctly have saved the live and federated it after the streaming', async function () { + this.timeout(120000) + + const liveDetails = await servers[0].videos.get({ id: liveVideoUUID }) + + await stopFfmpeg(ffmpegCommand) + + await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID) + await waitJobs(servers) + + const video = await findExternalSavedVideo(servers[0], liveDetails) + expect(video).to.exist + + for (const server of servers) { + await server.videos.get({ id: video.uuid }) + } + + lastReplayUUID = video.uuid + }) + + it('Should have appropriate ended session and replay live session', async function () { + const { data, total } = await servers[0].live.listSessions({ videoId: liveVideoUUID }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const sessionFromLive = data[0] + const sessionFromReplay = await servers[0].live.getReplaySession({ videoId: lastReplayUUID }) + + for (const session of [ sessionFromLive, sessionFromReplay ]) { + expect(session.startDate).to.exist + expect(session.endDate).to.exist + + expect(session.replaySettings).to.exist + expect(session.replaySettings.privacy).to.equal(VideoPrivacy.UNLISTED) + + expect(session.error).to.not.exist + + expect(session.replayVideo).to.exist + expect(session.replayVideo.id).to.exist + expect(session.replayVideo.shortUUID).to.exist + expect(session.replayVideo.uuid).to.equal(lastReplayUUID) + } + }) + + it('Should have the first live replay with correct settings', async function () { + await checkVideosExist(lastReplayUUID, false, HttpStatusCode.OK_200) + await checkVideoState(lastReplayUUID, VideoState.PUBLISHED) + await checkVideoPrivacy(lastReplayUUID, VideoPrivacy.UNLISTED) + }) + }) + + describe('With a second live and its replay', function () { + + it('Should update the replay settings', async function () { + await servers[0].live.update({ videoId: liveVideoUUID, fields: { replaySettings: { privacy: VideoPrivacy.PUBLIC } } }) + await waitJobs(servers) + + const live = await servers[0].live.get({ videoId: liveVideoUUID }) + + expect(live.saveReplay).to.be.true + expect(live.replaySettings).to.exist + expect(live.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC) + + }) + + it('Should correctly have updated the live and federated it when streaming in the live', async function () { + this.timeout(120000) + + ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + + await waitJobs(servers) + + await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) + await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) + }) + + it('Should correctly have saved the live and federated it after the streaming', async function () { + this.timeout(120000) + + const liveDetails = await servers[0].videos.get({ id: liveVideoUUID }) + + await stopFfmpeg(ffmpegCommand) + + await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID) + await waitJobs(servers) + + const video = await findExternalSavedVideo(servers[0], liveDetails) + expect(video).to.exist + + for (const server of servers) { + await server.videos.get({ id: video.uuid }) + } + + lastReplayUUID = video.uuid + }) + + it('Should have appropriate ended session and replay live session', async function () { + const { data, total } = await servers[0].live.listSessions({ videoId: liveVideoUUID }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + const sessionFromLive = data[1] + const sessionFromReplay = await servers[0].live.getReplaySession({ videoId: lastReplayUUID }) + + for (const session of [ sessionFromLive, sessionFromReplay ]) { + expect(session.startDate).to.exist + expect(session.endDate).to.exist + + expect(session.replaySettings).to.exist + expect(session.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC) + + expect(session.error).to.not.exist + + expect(session.replayVideo).to.exist + expect(session.replayVideo.id).to.exist + expect(session.replayVideo.shortUUID).to.exist + expect(session.replayVideo.uuid).to.equal(lastReplayUUID) + } + }) + + it('Should have the first live replay with correct settings', async function () { + await checkVideosExist(lastReplayUUID, true, HttpStatusCode.OK_200) + await checkVideoState(lastReplayUUID, VideoState.PUBLISHED) + await checkVideoPrivacy(lastReplayUUID, VideoPrivacy.PUBLIC) + }) + + it('Should have cleaned up the live files', async function () { + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) + }) + + it('Should correctly terminate the stream on blacklist and blacklist the saved replay video', async function () { + this.timeout(120000) + + await servers[0].videos.remove({ id: lastReplayUUID }) + const { liveDetails } = await publishLiveAndBlacklist({ + permanent: true, + replay: true, + replaySettings: { privacy: VideoPrivacy.PUBLIC } + }) + + const replay = await findExternalSavedVideo(servers[0], liveDetails) + expect(replay).to.exist + + for (const videoId of [ liveVideoUUID, replay.uuid ]) { + await checkVideosExist(videoId, false) + + await servers[0].videos.get({ id: videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await servers[1].videos.get({ id: videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) + }) + + it('Should correctly terminate the stream on delete and not save the video', async function () { + this.timeout(120000) + + const { liveDetails } = await publishLiveAndDelete({ + permanent: true, + replay: true, + replaySettings: { privacy: VideoPrivacy.PUBLIC } + }) + + const replay = await findExternalSavedVideo(servers[0], liveDetails) + expect(replay).to.not.exist + + await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404) + await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) + }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/live/live-socket-messages.ts b/packages/tests/src/api/live/live-socket-messages.ts new file mode 100644 index 000000000..80bae154c --- /dev/null +++ b/packages/tests/src/api/live/live-socket-messages.ts @@ -0,0 +1,186 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { LiveVideoEventPayload, VideoPrivacy, VideoState, VideoStateType } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs, + waitUntilLivePublishedOnAllServers +} from '@peertube/peertube-server-commands' + +describe('Test live socket messages', function () { + let servers: PeerTubeServer[] = [] + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + transcoding: { + enabled: false + } + } + } + }) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + describe('Live socket messages', function () { + + async function createLiveWrapper () { + const liveAttributes = { + name: 'live video', + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + + const { uuid } = await servers[0].live.create({ fields: liveAttributes }) + return uuid + } + + it('Should correctly send a message when the live starts and ends', async function () { + this.timeout(60000) + + const localStateChanges: VideoStateType[] = [] + const remoteStateChanges: VideoStateType[] = [] + + const liveVideoUUID = await createLiveWrapper() + await waitJobs(servers) + + { + const videoId = await servers[0].videos.getId({ uuid: liveVideoUUID }) + + const localSocket = servers[0].socketIO.getLiveNotificationSocket() + localSocket.on('state-change', data => localStateChanges.push(data.state)) + localSocket.emit('subscribe', { videoId }) + } + + { + const videoId = await servers[1].videos.getId({ uuid: liveVideoUUID }) + + const remoteSocket = servers[1].socketIO.getLiveNotificationSocket() + remoteSocket.on('state-change', data => remoteStateChanges.push(data.state)) + remoteSocket.emit('subscribe', { videoId }) + } + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + + await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + await waitJobs(servers) + + for (const stateChanges of [ localStateChanges, remoteStateChanges ]) { + expect(stateChanges).to.have.length.at.least(1) + expect(stateChanges[stateChanges.length - 1]).to.equal(VideoState.PUBLISHED) + } + + await stopFfmpeg(ffmpegCommand) + + for (const server of servers) { + await server.live.waitUntilEnded({ videoId: liveVideoUUID }) + } + await waitJobs(servers) + + for (const stateChanges of [ localStateChanges, remoteStateChanges ]) { + expect(stateChanges).to.have.length.at.least(2) + expect(stateChanges[stateChanges.length - 1]).to.equal(VideoState.LIVE_ENDED) + } + }) + + it('Should correctly send views change notification', async function () { + this.timeout(60000) + + let localLastVideoViews = 0 + let remoteLastVideoViews = 0 + + const liveVideoUUID = await createLiveWrapper() + await waitJobs(servers) + + { + const videoId = await servers[0].videos.getId({ uuid: liveVideoUUID }) + + const localSocket = servers[0].socketIO.getLiveNotificationSocket() + localSocket.on('views-change', (data: LiveVideoEventPayload) => { localLastVideoViews = data.viewers }) + localSocket.emit('subscribe', { videoId }) + } + + { + const videoId = await servers[1].videos.getId({ uuid: liveVideoUUID }) + + const remoteSocket = servers[1].socketIO.getLiveNotificationSocket() + remoteSocket.on('views-change', (data: LiveVideoEventPayload) => { remoteLastVideoViews = data.viewers }) + remoteSocket.emit('subscribe', { videoId }) + } + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + + await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + await waitJobs(servers) + + expect(localLastVideoViews).to.equal(0) + expect(remoteLastVideoViews).to.equal(0) + + await servers[0].views.simulateView({ id: liveVideoUUID }) + await servers[1].views.simulateView({ id: liveVideoUUID }) + + await waitJobs(servers) + + expect(localLastVideoViews).to.equal(2) + expect(remoteLastVideoViews).to.equal(2) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should not receive a notification after unsubscribe', async function () { + this.timeout(120000) + + const stateChanges: VideoStateType[] = [] + + const liveVideoUUID = await createLiveWrapper() + await waitJobs(servers) + + const videoId = await servers[0].videos.getId({ uuid: liveVideoUUID }) + + const socket = servers[0].socketIO.getLiveNotificationSocket() + socket.on('state-change', data => stateChanges.push(data.state)) + socket.emit('subscribe', { videoId }) + + const command = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + + await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + await waitJobs(servers) + + // Notifier waits before sending a notification + await wait(10000) + + expect(stateChanges).to.have.lengthOf(1) + socket.emit('unsubscribe', { videoId }) + + await stopFfmpeg(command) + await waitJobs(servers) + + expect(stateChanges).to.have.lengthOf(1) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/live/live.ts b/packages/tests/src/api/live/live.ts new file mode 100644 index 000000000..20804f889 --- /dev/null +++ b/packages/tests/src/api/live/live.ts @@ -0,0 +1,766 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { basename, join } from 'path' +import { getAllFiles, wait } from '@peertube/peertube-core-utils' +import { ffprobePromise, getVideoStream } from '@peertube/peertube-ffmpeg' +import { + HttpStatusCode, + LiveVideo, + LiveVideoCreate, + LiveVideoLatencyMode, + VideoDetails, + VideoPrivacy, + VideoState, + VideoStreamingPlaylistType +} from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + killallServers, + LiveCommand, + makeGetRequest, + makeRawRequest, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + testFfmpegStreamError, + waitJobs, + waitUntilLivePublishedOnAllServers +} from '@peertube/peertube-server-commands' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' +import { testLiveVideoResolutions } from '@tests/shared/live.js' +import { SQLCommand } from '@tests/shared/sql-command.js' + +describe('Test live', function () { + let servers: PeerTubeServer[] = [] + let commands: LiveCommand[] + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + latencySetting: { + enabled: true + }, + transcoding: { + enabled: false + } + } + } + }) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + commands = servers.map(s => s.live) + }) + + describe('Live creation, update and delete', function () { + let liveVideoUUID: string + + it('Should create a live with the appropriate parameters', async function () { + this.timeout(20000) + + const attributes: LiveVideoCreate = { + category: 1, + licence: 2, + language: 'fr', + description: 'super live description', + support: 'support field', + channelId: servers[0].store.channel.id, + nsfw: false, + waitTranscoding: false, + name: 'my super live', + tags: [ 'tag1', 'tag2' ], + commentsEnabled: false, + downloadEnabled: false, + saveReplay: true, + replaySettings: { privacy: VideoPrivacy.PUBLIC }, + latencyMode: LiveVideoLatencyMode.SMALL_LATENCY, + privacy: VideoPrivacy.PUBLIC, + previewfile: 'video_short1-preview.webm.jpg', + thumbnailfile: 'video_short1.webm.jpg' + } + + const live = await commands[0].create({ fields: attributes }) + liveVideoUUID = live.uuid + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: liveVideoUUID }) + + expect(video.category.id).to.equal(1) + expect(video.licence.id).to.equal(2) + expect(video.language.id).to.equal('fr') + expect(video.description).to.equal('super live description') + expect(video.support).to.equal('support field') + + expect(video.channel.name).to.equal(servers[0].store.channel.name) + expect(video.channel.host).to.equal(servers[0].store.channel.host) + + expect(video.isLive).to.be.true + + expect(video.nsfw).to.be.false + expect(video.waitTranscoding).to.be.false + expect(video.name).to.equal('my super live') + expect(video.tags).to.deep.equal([ 'tag1', 'tag2' ]) + expect(video.commentsEnabled).to.be.false + expect(video.downloadEnabled).to.be.false + expect(video.privacy.id).to.equal(VideoPrivacy.PUBLIC) + + await testImageGeneratedByFFmpeg(server.url, 'video_short1-preview.webm', video.previewPath) + await testImageGeneratedByFFmpeg(server.url, 'video_short1.webm', video.thumbnailPath) + + const live = await server.live.get({ videoId: liveVideoUUID }) + + if (server.url === servers[0].url) { + expect(live.rtmpUrl).to.equal('rtmp://' + server.hostname + ':' + servers[0].rtmpPort + '/live') + expect(live.streamKey).to.not.be.empty + + expect(live.replaySettings).to.exist + expect(live.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC) + } else { + expect(live.rtmpUrl).to.not.exist + expect(live.streamKey).to.not.exist + } + + expect(live.saveReplay).to.be.true + expect(live.latencyMode).to.equal(LiveVideoLatencyMode.SMALL_LATENCY) + } + }) + + it('Should have a default preview and thumbnail', async function () { + this.timeout(20000) + + const attributes: LiveVideoCreate = { + name: 'default live thumbnail', + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.UNLISTED, + nsfw: true + } + + const live = await commands[0].create({ fields: attributes }) + const videoId = live.uuid + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoId }) + expect(video.privacy.id).to.equal(VideoPrivacy.UNLISTED) + expect(video.nsfw).to.be.true + + await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should not have the live listed since nobody streams into', async function () { + for (const server of servers) { + const { total, data } = await server.videos.list() + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + }) + + it('Should not be able to update a live of another server', async function () { + await commands[1].update({ videoId: liveVideoUUID, fields: { saveReplay: false }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should update the live', async function () { + await commands[0].update({ videoId: liveVideoUUID, fields: { saveReplay: false, latencyMode: LiveVideoLatencyMode.DEFAULT } }) + await waitJobs(servers) + }) + + it('Have the live updated', async function () { + for (const server of servers) { + const live = await server.live.get({ videoId: liveVideoUUID }) + + if (server.url === servers[0].url) { + expect(live.rtmpUrl).to.equal('rtmp://' + server.hostname + ':' + servers[0].rtmpPort + '/live') + expect(live.streamKey).to.not.be.empty + } else { + expect(live.rtmpUrl).to.not.exist + expect(live.streamKey).to.not.exist + } + + expect(live.saveReplay).to.be.false + expect(live.replaySettings).to.not.exist + expect(live.latencyMode).to.equal(LiveVideoLatencyMode.DEFAULT) + } + }) + + it('Delete the live', async function () { + await servers[0].videos.remove({ id: liveVideoUUID }) + await waitJobs(servers) + }) + + it('Should have the live deleted', async function () { + for (const server of servers) { + await server.videos.get({ id: liveVideoUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await server.live.get({ videoId: liveVideoUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + }) + }) + + describe('Live filters', function () { + let ffmpegCommand: any + let liveVideoId: string + let vodVideoId: string + + before(async function () { + this.timeout(240000) + + vodVideoId = (await servers[0].videos.quickUpload({ name: 'vod video' })).uuid + + const liveOptions = { name: 'live', privacy: VideoPrivacy.PUBLIC, channelId: servers[0].store.channel.id } + const live = await commands[0].create({ fields: liveOptions }) + liveVideoId = live.uuid + + ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoId }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + await waitJobs(servers) + }) + + it('Should only display lives', async function () { + const { data, total } = await servers[0].videos.list({ isLive: true }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('live') + }) + + it('Should not display lives', async function () { + const { data, total } = await servers[0].videos.list({ isLive: false }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('vod video') + }) + + it('Should display my lives', async function () { + this.timeout(60000) + + await stopFfmpeg(ffmpegCommand) + await waitJobs(servers) + + const { data } = await servers[0].videos.listMyVideos({ isLive: true }) + + const result = data.every(v => v.isLive) + expect(result).to.be.true + }) + + it('Should not display my lives', async function () { + const { data } = await servers[0].videos.listMyVideos({ isLive: false }) + + const result = data.every(v => !v.isLive) + expect(result).to.be.true + }) + + after(async function () { + await servers[0].videos.remove({ id: vodVideoId }) + await servers[0].videos.remove({ id: liveVideoId }) + }) + }) + + describe('Stream checks', function () { + let liveVideo: LiveVideo & VideoDetails + let rtmpUrl: string + + before(function () { + rtmpUrl = 'rtmp://' + servers[0].hostname + ':' + servers[0].rtmpPort + '' + }) + + async function createLiveWrapper () { + const liveAttributes = { + name: 'user live', + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + saveReplay: false + } + + const { uuid } = await commands[0].create({ fields: liveAttributes }) + + const live = await commands[0].get({ videoId: uuid }) + const video = await servers[0].videos.get({ id: uuid }) + + return Object.assign(video, live) + } + + it('Should not allow a stream without the appropriate path', async function () { + this.timeout(60000) + + liveVideo = await createLiveWrapper() + + const command = sendRTMPStream({ rtmpBaseUrl: rtmpUrl + '/bad-live', streamKey: liveVideo.streamKey }) + await testFfmpegStreamError(command, true) + }) + + it('Should not allow a stream without the appropriate stream key', async function () { + this.timeout(60000) + + const command = sendRTMPStream({ rtmpBaseUrl: rtmpUrl + '/live', streamKey: 'bad-stream-key' }) + await testFfmpegStreamError(command, true) + }) + + it('Should succeed with the correct params', async function () { + this.timeout(60000) + + const command = sendRTMPStream({ rtmpBaseUrl: rtmpUrl + '/live', streamKey: liveVideo.streamKey }) + await testFfmpegStreamError(command, false) + }) + + it('Should list this live now someone stream into it', async function () { + for (const server of servers) { + const { total, data } = await server.videos.list() + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const video = data[0] + expect(video.name).to.equal('user live') + expect(video.isLive).to.be.true + } + }) + + it('Should not allow a stream on a live that was blacklisted', async function () { + this.timeout(60000) + + liveVideo = await createLiveWrapper() + + await servers[0].blacklist.add({ videoId: liveVideo.uuid }) + + const command = sendRTMPStream({ rtmpBaseUrl: rtmpUrl + '/live', streamKey: liveVideo.streamKey }) + await testFfmpegStreamError(command, true) + }) + + it('Should not allow a stream on a live that was deleted', async function () { + this.timeout(60000) + + liveVideo = await createLiveWrapper() + + await servers[0].videos.remove({ id: liveVideo.uuid }) + + const command = sendRTMPStream({ rtmpBaseUrl: rtmpUrl + '/live', streamKey: liveVideo.streamKey }) + await testFfmpegStreamError(command, true) + }) + }) + + describe('Live transcoding', function () { + let liveVideoId: string + let sqlCommandServer1: SQLCommand + + async function createLiveWrapper (saveReplay: boolean) { + const liveAttributes = { + name: 'live video', + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + saveReplay, + replaySettings: saveReplay + ? { privacy: VideoPrivacy.PUBLIC } + : undefined + } + + const { uuid } = await commands[0].create({ fields: liveAttributes }) + return uuid + } + + function updateConf (resolutions: number[]) { + return servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + maxDuration: -1, + transcoding: { + enabled: true, + resolutions: { + '144p': resolutions.includes(144), + '240p': resolutions.includes(240), + '360p': resolutions.includes(360), + '480p': resolutions.includes(480), + '720p': resolutions.includes(720), + '1080p': resolutions.includes(1080), + '2160p': resolutions.includes(2160) + } + } + } + } + }) + } + + before(async function () { + await updateConf([]) + + sqlCommandServer1 = new SQLCommand(servers[0]) + }) + + it('Should enable transcoding without additional resolutions', async function () { + this.timeout(120000) + + liveVideoId = await createLiveWrapper(false) + + const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + await waitJobs(servers) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId, + resolutions: [ 720 ], + transcoded: true + }) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should transcode audio only RTMP stream', async function () { + this.timeout(120000) + + liveVideoId = await createLiveWrapper(false) + + const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short_no_audio.mp4' }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + await waitJobs(servers) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should enable transcoding with some resolutions', async function () { + this.timeout(240000) + + const resolutions = [ 240, 480 ] + await updateConf(resolutions) + liveVideoId = await createLiveWrapper(false) + + const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + await waitJobs(servers) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId, + resolutions: resolutions.concat([ 720 ]), + transcoded: true + }) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should correctly set the appropriate bitrate depending on the input', async function () { + this.timeout(120000) + + liveVideoId = await createLiveWrapper(false) + + const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ + videoId: liveVideoId, + fixtureName: 'video_short.mp4', + copyCodecs: true + }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: liveVideoId }) + + const masterPlaylist = video.streamingPlaylists[0].playlistUrl + const probe = await ffprobePromise(masterPlaylist) + + const bitrates = probe.streams.map(s => parseInt(s.tags.variant_bitrate)) + for (const bitrate of bitrates) { + expect(bitrate).to.exist + expect(isNaN(bitrate)).to.be.false + expect(bitrate).to.be.below(61_000_000) // video_short.mp4 bitrate + } + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should enable transcoding with some resolutions and correctly save them', async function () { + this.timeout(500_000) + + const resolutions = [ 240, 360, 720 ] + + await updateConf(resolutions) + liveVideoId = await createLiveWrapper(true) + + const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short2.webm' }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + await waitJobs(servers) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId, + resolutions, + transcoded: true + }) + + await stopFfmpeg(ffmpegCommand) + await commands[0].waitUntilEnded({ videoId: liveVideoId }) + + await waitJobs(servers) + + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + + const maxBitrateLimits = { + 720: 6500 * 1000, // 60FPS + 360: 1250 * 1000, + 240: 700 * 1000 + } + + const minBitrateLimits = { + 720: 4800 * 1000, + 360: 1000 * 1000, + 240: 550 * 1000 + } + + for (const server of servers) { + const video = await server.videos.get({ id: liveVideoId }) + + expect(video.state.id).to.equal(VideoState.PUBLISHED) + expect(video.duration).to.be.greaterThan(1) + expect(video.files).to.have.lengthOf(0) + + const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS) + await makeRawRequest({ url: hlsPlaylist.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hlsPlaylist.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) + + // We should have generated random filenames + expect(basename(hlsPlaylist.playlistUrl)).to.not.equal('master.m3u8') + expect(basename(hlsPlaylist.segmentsSha256Url)).to.not.equal('segments-sha256.json') + + expect(hlsPlaylist.files).to.have.lengthOf(resolutions.length) + + for (const resolution of resolutions) { + const file = hlsPlaylist.files.find(f => f.resolution.id === resolution) + + expect(file).to.exist + expect(file.size).to.be.greaterThan(1) + + if (resolution >= 720) { + expect(file.fps).to.be.approximately(60, 10) + } else { + expect(file.fps).to.be.approximately(30, 3) + } + + const filename = basename(file.fileUrl) + expect(filename).to.not.contain(video.uuid) + + const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename)) + + const probe = await ffprobePromise(segmentPath) + const videoStream = await getVideoStream(segmentPath, probe) + + expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height]) + expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height]) + + await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + } + }) + + it('Should not generate an upper resolution than original file', async function () { + this.timeout(500_000) + + const resolutions = [ 240, 480 ] + await updateConf(resolutions) + + await servers[0].config.updateExistingSubConfig({ + newConfig: { + live: { + transcoding: { + alwaysTranscodeOriginalResolution: false + } + } + } + }) + + liveVideoId = await createLiveWrapper(true) + + const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short2.webm' }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + await waitJobs(servers) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId, + resolutions, + transcoded: true + }) + + await stopFfmpeg(ffmpegCommand) + await commands[0].waitUntilEnded({ videoId: liveVideoId }) + + await waitJobs(servers) + + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + + const video = await servers[0].videos.get({ id: liveVideoId }) + const hlsFiles = video.streamingPlaylists[0].files + + expect(video.files).to.have.lengthOf(0) + expect(hlsFiles).to.have.lengthOf(resolutions.length) + + // eslint-disable-next-line @typescript-eslint/require-array-sort-compare + expect(getAllFiles(video).map(f => f.resolution.id).sort()).to.deep.equal(resolutions) + }) + + it('Should only keep the original resolution if all resolutions are disabled', async function () { + this.timeout(600_000) + + await updateConf([]) + liveVideoId = await createLiveWrapper(true) + + const ffmpegCommand = await commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short2.webm' }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + await waitJobs(servers) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId, + resolutions: [ 720 ], + transcoded: true + }) + + await stopFfmpeg(ffmpegCommand) + await commands[0].waitUntilEnded({ videoId: liveVideoId }) + + await waitJobs(servers) + + await waitUntilLivePublishedOnAllServers(servers, liveVideoId) + + const video = await servers[0].videos.get({ id: liveVideoId }) + const hlsFiles = video.streamingPlaylists[0].files + + expect(video.files).to.have.lengthOf(0) + expect(hlsFiles).to.have.lengthOf(1) + + expect(hlsFiles[0].resolution.id).to.equal(720) + }) + + after(async function () { + await sqlCommandServer1.cleanup() + }) + }) + + describe('After a server restart', function () { + let liveVideoId: string + let liveVideoReplayId: string + let permanentLiveVideoReplayId: string + + let permanentLiveReplayName: string + + let beforeServerRestart: Date + + async function createLiveWrapper (options: { saveReplay: boolean, permanent: boolean }) { + const liveAttributes: LiveVideoCreate = { + name: 'live video', + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + saveReplay: options.saveReplay, + replaySettings: options.saveReplay + ? { privacy: VideoPrivacy.PUBLIC } + : undefined, + permanentLive: options.permanent + } + + const { uuid } = await commands[0].create({ fields: liveAttributes }) + return uuid + } + + before(async function () { + this.timeout(600_000) + + liveVideoId = await createLiveWrapper({ saveReplay: false, permanent: false }) + liveVideoReplayId = await createLiveWrapper({ saveReplay: true, permanent: false }) + permanentLiveVideoReplayId = await createLiveWrapper({ saveReplay: true, permanent: true }) + + await Promise.all([ + commands[0].sendRTMPStreamInVideo({ videoId: liveVideoId }), + commands[0].sendRTMPStreamInVideo({ videoId: permanentLiveVideoReplayId }), + commands[0].sendRTMPStreamInVideo({ videoId: liveVideoReplayId }) + ]) + + await Promise.all([ + commands[0].waitUntilPublished({ videoId: liveVideoId }), + commands[0].waitUntilPublished({ videoId: permanentLiveVideoReplayId }), + commands[0].waitUntilPublished({ videoId: liveVideoReplayId }) + ]) + + for (const videoUUID of [ liveVideoId, liveVideoReplayId, permanentLiveVideoReplayId ]) { + await commands[0].waitUntilSegmentGeneration({ + server: servers[0], + videoUUID, + playlistNumber: 0, + segment: 2 + }) + } + + { + const video = await servers[0].videos.get({ id: permanentLiveVideoReplayId }) + permanentLiveReplayName = video.name + ' - ' + new Date(video.publishedAt).toLocaleString() + } + + await killallServers([ servers[0] ]) + + beforeServerRestart = new Date() + await servers[0].run() + + await wait(5000) + await waitJobs(servers) + }) + + it('Should cleanup lives', async function () { + this.timeout(60000) + + await commands[0].waitUntilEnded({ videoId: liveVideoId }) + await commands[0].waitUntilWaiting({ videoId: permanentLiveVideoReplayId }) + }) + + it('Should save a non permanent live replay', async function () { + this.timeout(240000) + + await commands[0].waitUntilPublished({ videoId: liveVideoReplayId }) + + const session = await commands[0].getReplaySession({ videoId: liveVideoReplayId }) + expect(session.endDate).to.exist + expect(new Date(session.endDate)).to.be.above(beforeServerRestart) + }) + + it('Should have saved a permanent live replay', async function () { + this.timeout(120000) + + const { data } = await servers[0].videos.listMyVideos({ sort: '-publishedAt' }) + expect(data.find(v => v.name === permanentLiveReplayName)).to.exist + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/moderation/abuses.ts b/packages/tests/src/api/moderation/abuses.ts new file mode 100644 index 000000000..649de224e --- /dev/null +++ b/packages/tests/src/api/moderation/abuses.ts @@ -0,0 +1,887 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { AbuseMessage, AbusePredefinedReasonsString, AbuseState, AdminAbuse, UserAbuse } from '@peertube/peertube-models' +import { + AbusesCommand, + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test abuses', function () { + let servers: PeerTubeServer[] = [] + let abuseServer1: AdminAbuse + let abuseServer2: AdminAbuse + let commands: AbusesCommand[] + + before(async function () { + this.timeout(50000) + + // Run servers + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultChannelAvatar(servers) + await setDefaultAccountAvatar(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + commands = servers.map(s => s.abuses) + }) + + describe('Video abuses', function () { + + before(async function () { + this.timeout(50000) + + // Upload some videos on each servers + { + const attributes = { + name: 'my super name for server 1', + description: 'my super description for server 1' + } + await servers[0].videos.upload({ attributes }) + } + + { + const attributes = { + name: 'my super name for server 2', + description: 'my super description for server 2' + } + await servers[1].videos.upload({ attributes }) + } + + // Wait videos propagation, server 2 has transcoding enabled + await waitJobs(servers) + + const { data } = await servers[0].videos.list() + expect(data.length).to.equal(2) + + servers[0].store.videoCreated = data.find(video => video.name === 'my super name for server 1') + servers[1].store.videoCreated = data.find(video => video.name === 'my super name for server 2') + }) + + it('Should not have abuses', async function () { + const body = await commands[0].getAdminList() + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + }) + + it('Should report abuse on a local video', async function () { + this.timeout(15000) + + const reason = 'my super bad reason' + await commands[0].report({ videoId: servers[0].store.videoCreated.id, reason }) + + // We wait requests propagation, even if the server 1 is not supposed to make a request to server 2 + await waitJobs(servers) + }) + + it('Should have 1 video abuses on server 1 and 0 on server 2', async function () { + { + const body = await commands[0].getAdminList() + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(1) + + const abuse = body.data[0] + expect(abuse.reason).to.equal('my super bad reason') + + expect(abuse.reporterAccount.name).to.equal('root') + expect(abuse.reporterAccount.host).to.equal(servers[0].host) + + expect(abuse.video.id).to.equal(servers[0].store.videoCreated.id) + expect(abuse.video.channel).to.exist + + expect(abuse.comment).to.be.null + + expect(abuse.flaggedAccount.name).to.equal('root') + expect(abuse.flaggedAccount.host).to.equal(servers[0].host) + + expect(abuse.video.countReports).to.equal(1) + expect(abuse.video.nthReport).to.equal(1) + + expect(abuse.countReportsForReporter).to.equal(1) + expect(abuse.countReportsForReportee).to.equal(1) + } + + { + const body = await commands[1].getAdminList() + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + }) + + it('Should report abuse on a remote video', async function () { + const reason = 'my super bad reason 2' + const videoId = await servers[0].videos.getId({ uuid: servers[1].store.videoCreated.uuid }) + await commands[0].report({ videoId, reason }) + + // We wait requests propagation + await waitJobs(servers) + }) + + it('Should have 2 video abuses on server 1 and 1 on server 2', async function () { + { + const body = await commands[0].getAdminList() + + expect(body.total).to.equal(2) + expect(body.data.length).to.equal(2) + + const abuse1 = body.data[0] + expect(abuse1.reason).to.equal('my super bad reason') + expect(abuse1.reporterAccount.name).to.equal('root') + expect(abuse1.reporterAccount.host).to.equal(servers[0].host) + + expect(abuse1.video.id).to.equal(servers[0].store.videoCreated.id) + expect(abuse1.video.countReports).to.equal(1) + expect(abuse1.video.nthReport).to.equal(1) + + expect(abuse1.comment).to.be.null + + expect(abuse1.flaggedAccount.name).to.equal('root') + expect(abuse1.flaggedAccount.host).to.equal(servers[0].host) + + expect(abuse1.state.id).to.equal(AbuseState.PENDING) + expect(abuse1.state.label).to.equal('Pending') + expect(abuse1.moderationComment).to.be.null + + const abuse2 = body.data[1] + expect(abuse2.reason).to.equal('my super bad reason 2') + + expect(abuse2.reporterAccount.name).to.equal('root') + expect(abuse2.reporterAccount.host).to.equal(servers[0].host) + + expect(abuse2.video.uuid).to.equal(servers[1].store.videoCreated.uuid) + + expect(abuse2.comment).to.be.null + + expect(abuse2.flaggedAccount.name).to.equal('root') + expect(abuse2.flaggedAccount.host).to.equal(servers[1].host) + + expect(abuse2.state.id).to.equal(AbuseState.PENDING) + expect(abuse2.state.label).to.equal('Pending') + expect(abuse2.moderationComment).to.be.null + } + + { + const body = await commands[1].getAdminList() + expect(body.total).to.equal(1) + expect(body.data.length).to.equal(1) + + abuseServer2 = body.data[0] + expect(abuseServer2.reason).to.equal('my super bad reason 2') + expect(abuseServer2.reporterAccount.name).to.equal('root') + expect(abuseServer2.reporterAccount.host).to.equal(servers[0].host) + + expect(abuseServer2.flaggedAccount.name).to.equal('root') + expect(abuseServer2.flaggedAccount.host).to.equal(servers[1].host) + + expect(abuseServer2.state.id).to.equal(AbuseState.PENDING) + expect(abuseServer2.state.label).to.equal('Pending') + expect(abuseServer2.moderationComment).to.be.null + } + }) + + it('Should hide video abuses from blocked accounts', async function () { + { + const videoId = await servers[1].videos.getId({ uuid: servers[0].store.videoCreated.uuid }) + await commands[1].report({ videoId, reason: 'will mute this' }) + await waitJobs(servers) + + const body = await commands[0].getAdminList() + expect(body.total).to.equal(3) + } + + const accountToBlock = 'root@' + servers[1].host + + { + await servers[0].blocklist.addToServerBlocklist({ account: accountToBlock }) + + const body = await commands[0].getAdminList() + expect(body.total).to.equal(2) + + const abuse = body.data.find(a => a.reason === 'will mute this') + expect(abuse).to.be.undefined + } + + { + await servers[0].blocklist.removeFromServerBlocklist({ account: accountToBlock }) + + const body = await commands[0].getAdminList() + expect(body.total).to.equal(3) + } + }) + + it('Should hide video abuses from blocked servers', async function () { + const serverToBlock = servers[1].host + + { + await servers[0].blocklist.addToServerBlocklist({ server: serverToBlock }) + + const body = await commands[0].getAdminList() + expect(body.total).to.equal(2) + + const abuse = body.data.find(a => a.reason === 'will mute this') + expect(abuse).to.be.undefined + } + + { + await servers[0].blocklist.removeFromServerBlocklist({ server: serverToBlock }) + + const body = await commands[0].getAdminList() + expect(body.total).to.equal(3) + } + }) + + it('Should keep the video abuse when deleting the video', async function () { + await servers[1].videos.remove({ id: abuseServer2.video.uuid }) + + await waitJobs(servers) + + const body = await commands[1].getAdminList() + expect(body.total).to.equal(2, 'wrong number of videos returned') + expect(body.data).to.have.lengthOf(2, 'wrong number of videos returned') + + const abuse = body.data[0] + expect(abuse.id).to.equal(abuseServer2.id, 'wrong origin server id for first video') + expect(abuse.video.id).to.equal(abuseServer2.video.id, 'wrong video id') + expect(abuse.video.channel).to.exist + expect(abuse.video.deleted).to.be.true + }) + + it('Should include counts of reports from reporter and reportee', async function () { + // register a second user to have two reporters/reportees + const user = { username: 'user2', password: 'password' } + await servers[0].users.create({ ...user }) + const userAccessToken = await servers[0].login.getAccessToken(user) + + // upload a third video via this user + const attributes = { + name: 'my second super name for server 1', + description: 'my second super description for server 1' + } + const { id } = await servers[0].videos.upload({ token: userAccessToken, attributes }) + const video3Id = id + + // resume with the test + const reason3 = 'my super bad reason 3' + await commands[0].report({ videoId: video3Id, reason: reason3 }) + + const reason4 = 'my super bad reason 4' + await commands[0].report({ token: userAccessToken, videoId: servers[0].store.videoCreated.id, reason: reason4 }) + + { + const body = await commands[0].getAdminList() + const abuses = body.data + + const abuseVideo3 = body.data.find(a => a.video.id === video3Id) + expect(abuseVideo3).to.not.be.undefined + expect(abuseVideo3.video.countReports).to.equal(1, 'wrong reports count for video 3') + expect(abuseVideo3.video.nthReport).to.equal(1, 'wrong report position in report list for video 3') + expect(abuseVideo3.countReportsForReportee).to.equal(1, 'wrong reports count for reporter on video 3 abuse') + expect(abuseVideo3.countReportsForReporter).to.equal(3, 'wrong reports count for reportee on video 3 abuse') + + const abuseServer1 = abuses.find(a => a.video.id === servers[0].store.videoCreated.id) + expect(abuseServer1.countReportsForReportee).to.equal(3, 'wrong reports count for reporter on video 1 abuse') + } + }) + + it('Should list predefined reasons as well as timestamps for the reported video', async function () { + const reason5 = 'my super bad reason 5' + const predefinedReasons5: AbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ] + const createRes = await commands[0].report({ + videoId: servers[0].store.videoCreated.id, + reason: reason5, + predefinedReasons: predefinedReasons5, + startAt: 1, + endAt: 5 + }) + + const body = await commands[0].getAdminList() + + { + const abuse = body.data.find(a => a.id === createRes.abuse.id) + expect(abuse.reason).to.equals(reason5) + expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, 'predefined reasons do not match the one reported') + expect(abuse.video.startAt).to.equal(1, "starting timestamp doesn't match the one reported") + expect(abuse.video.endAt).to.equal(5, "ending timestamp doesn't match the one reported") + } + }) + + it('Should delete the video abuse', async function () { + await commands[1].delete({ abuseId: abuseServer2.id }) + + await waitJobs(servers) + + { + const body = await commands[1].getAdminList() + expect(body.total).to.equal(1) + expect(body.data.length).to.equal(1) + expect(body.data[0].id).to.not.equal(abuseServer2.id) + } + + { + const body = await commands[0].getAdminList() + expect(body.total).to.equal(6) + } + }) + + it('Should list and filter video abuses', async function () { + async function list (query: Parameters[0]) { + const body = await commands[0].getAdminList(query) + + return body.data + } + + expect(await list({ id: 56 })).to.have.lengthOf(0) + expect(await list({ id: 1 })).to.have.lengthOf(1) + + expect(await list({ search: 'my super name for server 1' })).to.have.lengthOf(4) + expect(await list({ search: 'aaaaaaaaaaaaaaaaaaaaaaaaaa' })).to.have.lengthOf(0) + + expect(await list({ searchVideo: 'my second super name for server 1' })).to.have.lengthOf(1) + + expect(await list({ searchVideoChannel: 'root' })).to.have.lengthOf(4) + expect(await list({ searchVideoChannel: 'aaaa' })).to.have.lengthOf(0) + + expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1) + expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5) + + expect(await list({ searchReportee: 'root' })).to.have.lengthOf(5) + expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0) + + expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1) + expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0) + + expect(await list({ state: AbuseState.ACCEPTED })).to.have.lengthOf(0) + expect(await list({ state: AbuseState.PENDING })).to.have.lengthOf(6) + + expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1) + expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0) + }) + }) + + describe('Comment abuses', function () { + + async function getComment (server: PeerTubeServer, videoIdArg: number | string) { + const videoId = typeof videoIdArg === 'string' + ? await server.videos.getId({ uuid: videoIdArg }) + : videoIdArg + + const { data } = await server.comments.listThreads({ videoId }) + + return data[0] + } + + before(async function () { + this.timeout(50000) + + servers[0].store.videoCreated = await servers[0].videos.quickUpload({ name: 'server 1' }) + servers[1].store.videoCreated = await servers[1].videos.quickUpload({ name: 'server 2' }) + + await servers[0].comments.createThread({ videoId: servers[0].store.videoCreated.id, text: 'comment server 1' }) + await servers[1].comments.createThread({ videoId: servers[1].store.videoCreated.id, text: 'comment server 2' }) + + await waitJobs(servers) + }) + + it('Should report abuse on a comment', async function () { + this.timeout(15000) + + const comment = await getComment(servers[0], servers[0].store.videoCreated.id) + + const reason = 'it is a bad comment' + await commands[0].report({ commentId: comment.id, reason }) + + await waitJobs(servers) + }) + + it('Should have 1 comment abuse on server 1 and 0 on server 2', async function () { + { + const comment = await getComment(servers[0], servers[0].store.videoCreated.id) + const body = await commands[0].getAdminList({ filter: 'comment' }) + + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const abuse = body.data[0] + expect(abuse.reason).to.equal('it is a bad comment') + + expect(abuse.reporterAccount.name).to.equal('root') + expect(abuse.reporterAccount.host).to.equal(servers[0].host) + + expect(abuse.video).to.be.null + + expect(abuse.comment.deleted).to.be.false + expect(abuse.comment.id).to.equal(comment.id) + expect(abuse.comment.text).to.equal(comment.text) + expect(abuse.comment.video.name).to.equal('server 1') + expect(abuse.comment.video.id).to.equal(servers[0].store.videoCreated.id) + expect(abuse.comment.video.uuid).to.equal(servers[0].store.videoCreated.uuid) + + expect(abuse.countReportsForReporter).to.equal(5) + expect(abuse.countReportsForReportee).to.equal(5) + } + + { + const body = await commands[1].getAdminList({ filter: 'comment' }) + expect(body.total).to.equal(0) + expect(body.data.length).to.equal(0) + } + }) + + it('Should report abuse on a remote comment', async function () { + const comment = await getComment(servers[0], servers[1].store.videoCreated.uuid) + + const reason = 'it is a really bad comment' + await commands[0].report({ commentId: comment.id, reason }) + + await waitJobs(servers) + }) + + it('Should have 2 comment abuses on server 1 and 1 on server 2', async function () { + const commentServer2 = await getComment(servers[0], servers[1].store.videoCreated.shortUUID) + + { + const body = await commands[0].getAdminList({ filter: 'comment' }) + expect(body.total).to.equal(2) + expect(body.data.length).to.equal(2) + + const abuse = body.data[0] + expect(abuse.reason).to.equal('it is a bad comment') + expect(abuse.countReportsForReporter).to.equal(6) + expect(abuse.countReportsForReportee).to.equal(5) + + const abuse2 = body.data[1] + + expect(abuse2.reason).to.equal('it is a really bad comment') + + expect(abuse2.reporterAccount.name).to.equal('root') + expect(abuse2.reporterAccount.host).to.equal(servers[0].host) + + expect(abuse2.video).to.be.null + + expect(abuse2.comment.deleted).to.be.false + expect(abuse2.comment.id).to.equal(commentServer2.id) + expect(abuse2.comment.text).to.equal(commentServer2.text) + expect(abuse2.comment.video.name).to.equal('server 2') + expect(abuse2.comment.video.uuid).to.equal(servers[1].store.videoCreated.uuid) + + expect(abuse2.state.id).to.equal(AbuseState.PENDING) + expect(abuse2.state.label).to.equal('Pending') + + expect(abuse2.moderationComment).to.be.null + + expect(abuse2.countReportsForReporter).to.equal(6) + expect(abuse2.countReportsForReportee).to.equal(2) + } + + { + const body = await commands[1].getAdminList({ filter: 'comment' }) + expect(body.total).to.equal(1) + expect(body.data.length).to.equal(1) + + abuseServer2 = body.data[0] + expect(abuseServer2.reason).to.equal('it is a really bad comment') + expect(abuseServer2.reporterAccount.name).to.equal('root') + expect(abuseServer2.reporterAccount.host).to.equal(servers[0].host) + + expect(abuseServer2.state.id).to.equal(AbuseState.PENDING) + expect(abuseServer2.state.label).to.equal('Pending') + + expect(abuseServer2.moderationComment).to.be.null + + expect(abuseServer2.countReportsForReporter).to.equal(1) + expect(abuseServer2.countReportsForReportee).to.equal(1) + } + }) + + it('Should keep the comment abuse when deleting the comment', async function () { + const commentServer2 = await getComment(servers[0], servers[1].store.videoCreated.uuid) + + await servers[0].comments.delete({ videoId: servers[1].store.videoCreated.uuid, commentId: commentServer2.id }) + + await waitJobs(servers) + + const body = await commands[0].getAdminList({ filter: 'comment' }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + const abuse = body.data.find(a => a.comment?.id === commentServer2.id) + expect(abuse).to.not.be.undefined + + expect(abuse.comment.text).to.be.empty + expect(abuse.comment.video.name).to.equal('server 2') + expect(abuse.comment.deleted).to.be.true + }) + + it('Should delete the comment abuse', async function () { + await commands[1].delete({ abuseId: abuseServer2.id }) + + await waitJobs(servers) + + { + const body = await commands[1].getAdminList({ filter: 'comment' }) + expect(body.total).to.equal(0) + expect(body.data.length).to.equal(0) + } + + { + const body = await commands[0].getAdminList({ filter: 'comment' }) + expect(body.total).to.equal(2) + } + }) + + it('Should list and filter video abuses', async function () { + { + const body = await commands[0].getAdminList({ filter: 'comment', searchReportee: 'foo' }) + expect(body.total).to.equal(0) + } + + { + const body = await commands[0].getAdminList({ filter: 'comment', searchReportee: 'ot' }) + expect(body.total).to.equal(2) + } + + { + const body = await commands[0].getAdminList({ filter: 'comment', start: 1, count: 1, sort: 'createdAt' }) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].comment.text).to.be.empty + } + + { + const body = await commands[0].getAdminList({ filter: 'comment', start: 1, count: 1, sort: '-createdAt' }) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].comment.text).to.equal('comment server 1') + } + }) + }) + + describe('Account abuses', function () { + + function getAccountFromServer (server: PeerTubeServer, targetName: string, targetServer: PeerTubeServer) { + return server.accounts.get({ accountName: targetName + '@' + targetServer.host }) + } + + before(async function () { + this.timeout(50000) + + await servers[0].users.create({ username: 'user_1', password: 'donald' }) + + const token = await servers[1].users.generateUserAndToken('user_2') + await servers[1].videos.upload({ token, attributes: { name: 'super video' } }) + + await waitJobs(servers) + }) + + it('Should report abuse on an account', async function () { + this.timeout(15000) + + const account = await getAccountFromServer(servers[0], 'user_1', servers[0]) + + const reason = 'it is a bad account' + await commands[0].report({ accountId: account.id, reason }) + + await waitJobs(servers) + }) + + it('Should have 1 account abuse on server 1 and 0 on server 2', async function () { + { + const body = await commands[0].getAdminList({ filter: 'account' }) + + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const abuse = body.data[0] + expect(abuse.reason).to.equal('it is a bad account') + + expect(abuse.reporterAccount.name).to.equal('root') + expect(abuse.reporterAccount.host).to.equal(servers[0].host) + + expect(abuse.video).to.be.null + expect(abuse.comment).to.be.null + + expect(abuse.flaggedAccount.name).to.equal('user_1') + expect(abuse.flaggedAccount.host).to.equal(servers[0].host) + } + + { + const body = await commands[1].getAdminList({ filter: 'comment' }) + expect(body.total).to.equal(0) + expect(body.data.length).to.equal(0) + } + }) + + it('Should report abuse on a remote account', async function () { + const account = await getAccountFromServer(servers[0], 'user_2', servers[1]) + + const reason = 'it is a really bad account' + await commands[0].report({ accountId: account.id, reason }) + + await waitJobs(servers) + }) + + it('Should have 2 comment abuses on server 1 and 1 on server 2', async function () { + { + const body = await commands[0].getAdminList({ filter: 'account' }) + expect(body.total).to.equal(2) + expect(body.data.length).to.equal(2) + + const abuse: AdminAbuse = body.data[0] + expect(abuse.reason).to.equal('it is a bad account') + + const abuse2: AdminAbuse = body.data[1] + expect(abuse2.reason).to.equal('it is a really bad account') + + expect(abuse2.reporterAccount.name).to.equal('root') + expect(abuse2.reporterAccount.host).to.equal(servers[0].host) + + expect(abuse2.video).to.be.null + expect(abuse2.comment).to.be.null + + expect(abuse2.state.id).to.equal(AbuseState.PENDING) + expect(abuse2.state.label).to.equal('Pending') + + expect(abuse2.moderationComment).to.be.null + } + + { + const body = await commands[1].getAdminList({ filter: 'account' }) + expect(body.total).to.equal(1) + expect(body.data.length).to.equal(1) + + abuseServer2 = body.data[0] + + expect(abuseServer2.reason).to.equal('it is a really bad account') + + expect(abuseServer2.reporterAccount.name).to.equal('root') + expect(abuseServer2.reporterAccount.host).to.equal(servers[0].host) + + expect(abuseServer2.state.id).to.equal(AbuseState.PENDING) + expect(abuseServer2.state.label).to.equal('Pending') + + expect(abuseServer2.moderationComment).to.be.null + } + }) + + it('Should keep the account abuse when deleting the account', async function () { + const account = await getAccountFromServer(servers[1], 'user_2', servers[1]) + await servers[1].users.remove({ userId: account.userId }) + + await waitJobs(servers) + + const body = await commands[0].getAdminList({ filter: 'account' }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + const abuse = body.data.find(a => a.reason === 'it is a really bad account') + expect(abuse).to.not.be.undefined + }) + + it('Should delete the account abuse', async function () { + await commands[1].delete({ abuseId: abuseServer2.id }) + + await waitJobs(servers) + + { + const body = await commands[1].getAdminList({ filter: 'account' }) + expect(body.total).to.equal(0) + expect(body.data.length).to.equal(0) + } + + { + const body = await commands[0].getAdminList({ filter: 'account' }) + expect(body.total).to.equal(2) + + abuseServer1 = body.data[0] + } + }) + }) + + describe('Common actions on abuses', function () { + + it('Should update the state of an abuse', async function () { + await commands[0].update({ abuseId: abuseServer1.id, body: { state: AbuseState.REJECTED } }) + + const body = await commands[0].getAdminList({ id: abuseServer1.id }) + expect(body.data[0].state.id).to.equal(AbuseState.REJECTED) + }) + + it('Should add a moderation comment', async function () { + await commands[0].update({ abuseId: abuseServer1.id, body: { state: AbuseState.ACCEPTED, moderationComment: 'Valid' } }) + + const body = await commands[0].getAdminList({ id: abuseServer1.id }) + expect(body.data[0].state.id).to.equal(AbuseState.ACCEPTED) + expect(body.data[0].moderationComment).to.equal('Valid') + }) + }) + + describe('My abuses', async function () { + let abuseId1: number + let userAccessToken: string + + before(async function () { + userAccessToken = await servers[0].users.generateUserAndToken('user_42') + + await commands[0].report({ token: userAccessToken, videoId: servers[0].store.videoCreated.id, reason: 'user reason 1' }) + + const videoId = await servers[0].videos.getId({ uuid: servers[1].store.videoCreated.uuid }) + await commands[0].report({ token: userAccessToken, videoId, reason: 'user reason 2' }) + }) + + it('Should correctly list my abuses', async function () { + { + const body = await commands[0].getUserList({ token: userAccessToken, start: 0, count: 5, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const abuses = body.data + expect(abuses[0].reason).to.equal('user reason 1') + expect(abuses[1].reason).to.equal('user reason 2') + + abuseId1 = abuses[0].id + } + + { + const body = await commands[0].getUserList({ token: userAccessToken, start: 1, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const abuses: UserAbuse[] = body.data + expect(abuses[0].reason).to.equal('user reason 2') + } + + { + const body = await commands[0].getUserList({ token: userAccessToken, start: 1, count: 1, sort: '-createdAt' }) + expect(body.total).to.equal(2) + + const abuses: UserAbuse[] = body.data + expect(abuses[0].reason).to.equal('user reason 1') + } + }) + + it('Should correctly filter my abuses by id', async function () { + const body = await commands[0].getUserList({ token: userAccessToken, id: abuseId1 }) + expect(body.total).to.equal(1) + + const abuses: UserAbuse[] = body.data + expect(abuses[0].reason).to.equal('user reason 1') + }) + + it('Should correctly filter my abuses by search', async function () { + const body = await commands[0].getUserList({ token: userAccessToken, search: 'server 2' }) + expect(body.total).to.equal(1) + + const abuses: UserAbuse[] = body.data + expect(abuses[0].reason).to.equal('user reason 2') + }) + + it('Should correctly filter my abuses by state', async function () { + await commands[0].update({ abuseId: abuseId1, body: { state: AbuseState.REJECTED } }) + + const body = await commands[0].getUserList({ token: userAccessToken, state: AbuseState.REJECTED }) + expect(body.total).to.equal(1) + + const abuses: UserAbuse[] = body.data + expect(abuses[0].reason).to.equal('user reason 1') + }) + }) + + describe('Abuse messages', async function () { + let abuseId: number + let userToken: string + let abuseMessageUserId: number + let abuseMessageModerationId: number + + before(async function () { + userToken = await servers[0].users.generateUserAndToken('user_43') + + const body = await commands[0].report({ token: userToken, videoId: servers[0].store.videoCreated.id, reason: 'user 43 reason 1' }) + abuseId = body.abuse.id + }) + + it('Should create some messages on the abuse', async function () { + await commands[0].addMessage({ token: userToken, abuseId, message: 'message 1' }) + await commands[0].addMessage({ abuseId, message: 'message 2' }) + await commands[0].addMessage({ abuseId, message: 'message 3' }) + await commands[0].addMessage({ token: userToken, abuseId, message: 'message 4' }) + }) + + it('Should have the correct messages count when listing abuses', async function () { + const results = await Promise.all([ + commands[0].getAdminList({ start: 0, count: 50 }), + commands[0].getUserList({ token: userToken, start: 0, count: 50 }) + ]) + + for (const body of results) { + const abuses = body.data + const abuse = abuses.find(a => a.id === abuseId) + expect(abuse.countMessages).to.equal(4) + } + }) + + it('Should correctly list messages of this abuse', async function () { + const results = await Promise.all([ + commands[0].listMessages({ abuseId }), + commands[0].listMessages({ token: userToken, abuseId }) + ]) + + for (const body of results) { + expect(body.total).to.equal(4) + + const abuseMessages: AbuseMessage[] = body.data + + expect(abuseMessages[0].message).to.equal('message 1') + expect(abuseMessages[0].byModerator).to.be.false + expect(abuseMessages[0].account.name).to.equal('user_43') + + abuseMessageUserId = abuseMessages[0].id + + expect(abuseMessages[1].message).to.equal('message 2') + expect(abuseMessages[1].byModerator).to.be.true + expect(abuseMessages[1].account.name).to.equal('root') + + expect(abuseMessages[2].message).to.equal('message 3') + expect(abuseMessages[2].byModerator).to.be.true + expect(abuseMessages[2].account.name).to.equal('root') + abuseMessageModerationId = abuseMessages[2].id + + expect(abuseMessages[3].message).to.equal('message 4') + expect(abuseMessages[3].byModerator).to.be.false + expect(abuseMessages[3].account.name).to.equal('user_43') + } + }) + + it('Should delete messages', async function () { + await commands[0].deleteMessage({ abuseId, messageId: abuseMessageModerationId }) + await commands[0].deleteMessage({ token: userToken, abuseId, messageId: abuseMessageUserId }) + + const results = await Promise.all([ + commands[0].listMessages({ abuseId }), + commands[0].listMessages({ token: userToken, abuseId }) + ]) + + for (const body of results) { + expect(body.total).to.equal(2) + + const abuseMessages: AbuseMessage[] = body.data + expect(abuseMessages[0].message).to.equal('message 2') + expect(abuseMessages[1].message).to.equal('message 4') + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/moderation/blocklist-notification.ts b/packages/tests/src/api/moderation/blocklist-notification.ts new file mode 100644 index 000000000..abf36313b --- /dev/null +++ b/packages/tests/src/api/moderation/blocklist-notification.ts @@ -0,0 +1,231 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { UserNotificationType, UserNotificationType_Type } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +async function checkNotifications (server: PeerTubeServer, token: string, expected: UserNotificationType_Type[]) { + const { data } = await server.notifications.list({ token, start: 0, count: 10, unread: true }) + expect(data).to.have.lengthOf(expected.length) + + for (const type of expected) { + expect(data.find(n => n.type === type)).to.exist + } +} + +describe('Test blocklist notifications', function () { + let servers: PeerTubeServer[] + let videoUUID: string + + let userToken1: string + let userToken2: string + let remoteUserToken: string + + async function resetState () { + try { + await servers[1].subscriptions.remove({ token: remoteUserToken, uri: 'user1_channel@' + servers[0].host }) + await servers[1].subscriptions.remove({ token: remoteUserToken, uri: 'user2_channel@' + servers[0].host }) + } catch {} + + await waitJobs(servers) + + await servers[0].notifications.markAsReadAll({ token: userToken1 }) + await servers[0].notifications.markAsReadAll({ token: userToken2 }) + + { + const { uuid } = await servers[0].videos.upload({ token: userToken1, attributes: { name: 'video' } }) + videoUUID = uuid + + await waitJobs(servers) + } + + { + await servers[1].comments.createThread({ + token: remoteUserToken, + videoId: videoUUID, + text: '@user2@' + servers[0].host + ' hello' + }) + } + + { + + await servers[1].subscriptions.add({ token: remoteUserToken, targetUri: 'user1_channel@' + servers[0].host }) + await servers[1].subscriptions.add({ token: remoteUserToken, targetUri: 'user2_channel@' + servers[0].host }) + } + + await waitJobs(servers) + } + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + { + const user = { username: 'user1', password: 'password' } + await servers[0].users.create({ + username: user.username, + password: user.password, + videoQuota: -1, + videoQuotaDaily: -1 + }) + + userToken1 = await servers[0].login.getAccessToken(user) + await servers[0].videos.upload({ token: userToken1, attributes: { name: 'video user 1' } }) + } + + { + const user = { username: 'user2', password: 'password' } + await servers[0].users.create({ username: user.username, password: user.password }) + + userToken2 = await servers[0].login.getAccessToken(user) + } + + { + const user = { username: 'user3', password: 'password' } + await servers[1].users.create({ username: user.username, password: user.password }) + + remoteUserToken = await servers[1].login.getAccessToken(user) + } + + await doubleFollow(servers[0], servers[1]) + }) + + describe('User blocks another user', function () { + + before(async function () { + this.timeout(30000) + + await resetState() + }) + + it('Should have appropriate notifications', async function () { + const notifs = [ UserNotificationType.NEW_COMMENT_ON_MY_VIDEO, UserNotificationType.NEW_FOLLOW ] + await checkNotifications(servers[0], userToken1, notifs) + }) + + it('Should block an account', async function () { + await servers[0].blocklist.addToMyBlocklist({ token: userToken1, account: 'user3@' + servers[1].host }) + await waitJobs(servers) + }) + + it('Should not have notifications from this account', async function () { + await checkNotifications(servers[0], userToken1, []) + }) + + it('Should have notifications of this account on user 2', async function () { + const notifs = [ UserNotificationType.COMMENT_MENTION, UserNotificationType.NEW_FOLLOW ] + + await checkNotifications(servers[0], userToken2, notifs) + + await servers[0].blocklist.removeFromMyBlocklist({ token: userToken1, account: 'user3@' + servers[1].host }) + }) + }) + + describe('User blocks another server', function () { + + before(async function () { + this.timeout(30000) + + await resetState() + }) + + it('Should have appropriate notifications', async function () { + const notifs = [ UserNotificationType.NEW_COMMENT_ON_MY_VIDEO, UserNotificationType.NEW_FOLLOW ] + await checkNotifications(servers[0], userToken1, notifs) + }) + + it('Should block an account', async function () { + await servers[0].blocklist.addToMyBlocklist({ token: userToken1, server: servers[1].host }) + await waitJobs(servers) + }) + + it('Should not have notifications from this account', async function () { + await checkNotifications(servers[0], userToken1, []) + }) + + it('Should have notifications of this account on user 2', async function () { + const notifs = [ UserNotificationType.COMMENT_MENTION, UserNotificationType.NEW_FOLLOW ] + + await checkNotifications(servers[0], userToken2, notifs) + + await servers[0].blocklist.removeFromMyBlocklist({ token: userToken1, server: servers[1].host }) + }) + }) + + describe('Server blocks a user', function () { + + before(async function () { + this.timeout(30000) + + await resetState() + }) + + it('Should have appropriate notifications', async function () { + { + const notifs = [ UserNotificationType.NEW_COMMENT_ON_MY_VIDEO, UserNotificationType.NEW_FOLLOW ] + await checkNotifications(servers[0], userToken1, notifs) + } + + { + const notifs = [ UserNotificationType.COMMENT_MENTION, UserNotificationType.NEW_FOLLOW ] + await checkNotifications(servers[0], userToken2, notifs) + } + }) + + it('Should block an account', async function () { + await servers[0].blocklist.addToServerBlocklist({ account: 'user3@' + servers[1].host }) + await waitJobs(servers) + }) + + it('Should not have notifications from this account', async function () { + await checkNotifications(servers[0], userToken1, []) + await checkNotifications(servers[0], userToken2, []) + + await servers[0].blocklist.removeFromServerBlocklist({ account: 'user3@' + servers[1].host }) + }) + }) + + describe('Server blocks a server', function () { + + before(async function () { + this.timeout(30000) + + await resetState() + }) + + it('Should have appropriate notifications', async function () { + { + const notifs = [ UserNotificationType.NEW_COMMENT_ON_MY_VIDEO, UserNotificationType.NEW_FOLLOW ] + await checkNotifications(servers[0], userToken1, notifs) + } + + { + const notifs = [ UserNotificationType.COMMENT_MENTION, UserNotificationType.NEW_FOLLOW ] + await checkNotifications(servers[0], userToken2, notifs) + } + }) + + it('Should block an account', async function () { + await servers[0].blocklist.addToServerBlocklist({ server: servers[1].host }) + await waitJobs(servers) + }) + + it('Should not have notifications from this account', async function () { + await checkNotifications(servers[0], userToken1, []) + await checkNotifications(servers[0], userToken2, []) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/moderation/blocklist.ts b/packages/tests/src/api/moderation/blocklist.ts new file mode 100644 index 000000000..a84515241 --- /dev/null +++ b/packages/tests/src/api/moderation/blocklist.ts @@ -0,0 +1,902 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { UserNotificationType } from '@peertube/peertube-models' +import { + BlocklistCommand, + cleanupTests, + CommentsCommand, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + waitJobs +} from '@peertube/peertube-server-commands' + +async function checkAllVideos (server: PeerTubeServer, token: string) { + { + const { data } = await server.videos.listWithToken({ token }) + expect(data).to.have.lengthOf(5) + } + + { + const { data } = await server.videos.list() + expect(data).to.have.lengthOf(5) + } +} + +async function checkAllComments (server: PeerTubeServer, token: string, videoUUID: string) { + const { data } = await server.comments.listThreads({ videoId: videoUUID, start: 0, count: 25, sort: '-createdAt', token }) + + const threads = data.filter(t => t.isDeleted === false) + expect(threads).to.have.lengthOf(2) + + for (const thread of threads) { + const tree = await server.comments.getThread({ videoId: videoUUID, threadId: thread.id, token }) + expect(tree.children).to.have.lengthOf(1) + } +} + +async function checkCommentNotification ( + mainServer: PeerTubeServer, + comment: { server: PeerTubeServer, token: string, videoUUID: string, text: string }, + check: 'presence' | 'absence' +) { + const command = comment.server.comments + + const { threadId, createdAt } = await command.createThread({ token: comment.token, videoId: comment.videoUUID, text: comment.text }) + + await waitJobs([ mainServer, comment.server ]) + + const { data } = await mainServer.notifications.list({ start: 0, count: 30 }) + const commentNotifications = data.filter(n => n.comment && n.comment.video.uuid === comment.videoUUID && n.createdAt >= createdAt) + + if (check === 'presence') expect(commentNotifications).to.have.lengthOf(1) + else expect(commentNotifications).to.have.lengthOf(0) + + await command.delete({ token: comment.token, videoId: comment.videoUUID, commentId: threadId }) + + await waitJobs([ mainServer, comment.server ]) +} + +describe('Test blocklist', function () { + let servers: PeerTubeServer[] + let videoUUID1: string + let videoUUID2: string + let videoUUID3: string + let userToken1: string + let userModeratorToken: string + let userToken2: string + + let command: BlocklistCommand + let commentsCommand: CommentsCommand[] + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + await setAccessTokensToServers(servers) + await setDefaultAccountAvatar(servers) + + command = servers[0].blocklist + commentsCommand = servers.map(s => s.comments) + + { + const user = { username: 'user1', password: 'password' } + await servers[0].users.create({ username: user.username, password: user.password }) + + userToken1 = await servers[0].login.getAccessToken(user) + await servers[0].videos.upload({ token: userToken1, attributes: { name: 'video user 1' } }) + } + + { + const user = { username: 'moderator', password: 'password' } + await servers[0].users.create({ username: user.username, password: user.password }) + + userModeratorToken = await servers[0].login.getAccessToken(user) + } + + { + const user = { username: 'user2', password: 'password' } + await servers[1].users.create({ username: user.username, password: user.password }) + + userToken2 = await servers[1].login.getAccessToken(user) + await servers[1].videos.upload({ token: userToken2, attributes: { name: 'video user 2' } }) + } + + { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video server 1' } }) + videoUUID1 = uuid + } + + { + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'video server 2' } }) + videoUUID2 = uuid + } + + { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video 2 server 1' } }) + videoUUID3 = uuid + } + + await doubleFollow(servers[0], servers[1]) + await doubleFollow(servers[0], servers[2]) + + { + const created = await commentsCommand[0].createThread({ videoId: videoUUID1, text: 'comment root 1' }) + const reply = await commentsCommand[0].addReply({ + token: userToken1, + videoId: videoUUID1, + toCommentId: created.id, + text: 'comment user 1' + }) + await commentsCommand[0].addReply({ videoId: videoUUID1, toCommentId: reply.id, text: 'comment root 1' }) + } + + { + const created = await commentsCommand[0].createThread({ token: userToken1, videoId: videoUUID1, text: 'comment user 1' }) + await commentsCommand[0].addReply({ videoId: videoUUID1, toCommentId: created.id, text: 'comment root 1' }) + } + + await waitJobs(servers) + }) + + describe('User blocklist', function () { + + describe('When managing account blocklist', function () { + it('Should list all videos', function () { + return checkAllVideos(servers[0], servers[0].accessToken) + }) + + it('Should list the comments', function () { + return checkAllComments(servers[0], servers[0].accessToken, videoUUID1) + }) + + it('Should block a remote account', async function () { + await command.addToMyBlocklist({ account: 'user2@' + servers[1].host }) + }) + + it('Should hide its videos', async function () { + const { data } = await servers[0].videos.listWithToken() + + expect(data).to.have.lengthOf(4) + + const v = data.find(v => v.name === 'video user 2') + expect(v).to.be.undefined + }) + + it('Should block a local account', async function () { + await command.addToMyBlocklist({ account: 'user1' }) + }) + + it('Should hide its videos', async function () { + const { data } = await servers[0].videos.listWithToken() + + expect(data).to.have.lengthOf(3) + + const v = data.find(v => v.name === 'video user 1') + expect(v).to.be.undefined + }) + + it('Should hide its comments', async function () { + const { data } = await commentsCommand[0].listThreads({ + token: servers[0].accessToken, + videoId: videoUUID1, + start: 0, + count: 25, + sort: '-createdAt' + }) + + expect(data).to.have.lengthOf(1) + expect(data[0].totalReplies).to.equal(1) + + const t = data.find(t => t.text === 'comment user 1') + expect(t).to.be.undefined + + for (const thread of data) { + const tree = await commentsCommand[0].getThread({ + videoId: videoUUID1, + threadId: thread.id, + token: servers[0].accessToken + }) + expect(tree.children).to.have.lengthOf(0) + } + }) + + it('Should not have notifications from blocked accounts', async function () { + this.timeout(20000) + + { + const comment = { server: servers[0], token: userToken1, videoUUID: videoUUID1, text: 'hidden comment' } + await checkCommentNotification(servers[0], comment, 'absence') + } + + { + const comment = { + server: servers[0], + token: userToken1, + videoUUID: videoUUID2, + text: 'hello @root@' + servers[0].host + } + await checkCommentNotification(servers[0], comment, 'absence') + } + }) + + it('Should list all the videos with another user', async function () { + return checkAllVideos(servers[0], userToken1) + }) + + it('Should list blocked accounts', async function () { + { + const body = await command.listMyAccountBlocklist({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const block = body.data[0] + expect(block.byAccount.displayName).to.equal('root') + expect(block.byAccount.name).to.equal('root') + expect(block.blockedAccount.displayName).to.equal('user2') + expect(block.blockedAccount.name).to.equal('user2') + expect(block.blockedAccount.host).to.equal('' + servers[1].host) + } + + { + const body = await command.listMyAccountBlocklist({ start: 1, count: 2, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const block = body.data[0] + expect(block.byAccount.displayName).to.equal('root') + expect(block.byAccount.name).to.equal('root') + expect(block.blockedAccount.displayName).to.equal('user1') + expect(block.blockedAccount.name).to.equal('user1') + expect(block.blockedAccount.host).to.equal('' + servers[0].host) + } + }) + + it('Should search blocked accounts', async function () { + const body = await command.listMyAccountBlocklist({ start: 0, count: 10, search: 'user2' }) + expect(body.total).to.equal(1) + + expect(body.data[0].blockedAccount.name).to.equal('user2') + }) + + it('Should get blocked status', async function () { + const remoteHandle = 'user2@' + servers[1].host + const localHandle = 'user1@' + servers[0].host + const unknownHandle = 'user5@' + servers[0].host + + { + const status = await command.getStatus({ accounts: [ remoteHandle ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(1) + expect(status.accounts[remoteHandle].blockedByUser).to.be.false + expect(status.accounts[remoteHandle].blockedByServer).to.be.false + + expect(Object.keys(status.hosts)).to.have.lengthOf(0) + } + + { + const status = await command.getStatus({ token: servers[0].accessToken, accounts: [ remoteHandle ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(1) + expect(status.accounts[remoteHandle].blockedByUser).to.be.true + expect(status.accounts[remoteHandle].blockedByServer).to.be.false + + expect(Object.keys(status.hosts)).to.have.lengthOf(0) + } + + { + const status = await command.getStatus({ token: servers[0].accessToken, accounts: [ localHandle, remoteHandle, unknownHandle ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(3) + + for (const handle of [ localHandle, remoteHandle ]) { + expect(status.accounts[handle].blockedByUser).to.be.true + expect(status.accounts[handle].blockedByServer).to.be.false + } + + expect(status.accounts[unknownHandle].blockedByUser).to.be.false + expect(status.accounts[unknownHandle].blockedByServer).to.be.false + + expect(Object.keys(status.hosts)).to.have.lengthOf(0) + } + }) + + it('Should not allow a remote blocked user to comment my videos', async function () { + this.timeout(60000) + + { + await commentsCommand[1].createThread({ token: userToken2, videoId: videoUUID3, text: 'comment user 2' }) + await waitJobs(servers) + + await commentsCommand[0].createThread({ token: servers[0].accessToken, videoId: videoUUID3, text: 'uploader' }) + await waitJobs(servers) + + const commentId = await commentsCommand[1].findCommentId({ videoId: videoUUID3, text: 'uploader' }) + const message = 'reply by user 2' + const reply = await commentsCommand[1].addReply({ token: userToken2, videoId: videoUUID3, toCommentId: commentId, text: message }) + await commentsCommand[1].addReply({ videoId: videoUUID3, toCommentId: reply.id, text: 'another reply' }) + + await waitJobs(servers) + } + + // Server 2 has all the comments + { + const { data } = await commentsCommand[1].listThreads({ videoId: videoUUID3, count: 25, sort: '-createdAt' }) + + expect(data).to.have.lengthOf(2) + expect(data[0].text).to.equal('uploader') + expect(data[1].text).to.equal('comment user 2') + + const tree = await commentsCommand[1].getThread({ videoId: videoUUID3, threadId: data[0].id }) + expect(tree.children).to.have.lengthOf(1) + expect(tree.children[0].comment.text).to.equal('reply by user 2') + expect(tree.children[0].children).to.have.lengthOf(1) + expect(tree.children[0].children[0].comment.text).to.equal('another reply') + } + + // Server 1 and 3 should only have uploader comments + for (const server of [ servers[0], servers[2] ]) { + const { data } = await server.comments.listThreads({ videoId: videoUUID3, count: 25, sort: '-createdAt' }) + + expect(data).to.have.lengthOf(1) + expect(data[0].text).to.equal('uploader') + + const tree = await server.comments.getThread({ videoId: videoUUID3, threadId: data[0].id }) + + if (server.serverNumber === 1) expect(tree.children).to.have.lengthOf(0) + else expect(tree.children).to.have.lengthOf(1) + } + }) + + it('Should unblock the remote account', async function () { + await command.removeFromMyBlocklist({ account: 'user2@' + servers[1].host }) + }) + + it('Should display its videos', async function () { + const { data } = await servers[0].videos.listWithToken() + expect(data).to.have.lengthOf(4) + + const v = data.find(v => v.name === 'video user 2') + expect(v).not.to.be.undefined + }) + + it('Should display its comments on my video', async function () { + for (const server of servers) { + const { data } = await server.comments.listThreads({ videoId: videoUUID3, count: 25, sort: '-createdAt' }) + + // Server 3 should not have 2 comment threads, because server 1 did not forward the server 2 comment + if (server.serverNumber === 3) { + expect(data).to.have.lengthOf(1) + continue + } + + expect(data).to.have.lengthOf(2) + expect(data[0].text).to.equal('uploader') + expect(data[1].text).to.equal('comment user 2') + + const tree = await server.comments.getThread({ videoId: videoUUID3, threadId: data[0].id }) + expect(tree.children).to.have.lengthOf(1) + expect(tree.children[0].comment.text).to.equal('reply by user 2') + expect(tree.children[0].children).to.have.lengthOf(1) + expect(tree.children[0].children[0].comment.text).to.equal('another reply') + } + }) + + it('Should unblock the local account', async function () { + await command.removeFromMyBlocklist({ account: 'user1' }) + }) + + it('Should display its comments', function () { + return checkAllComments(servers[0], servers[0].accessToken, videoUUID1) + }) + + it('Should have a notification from a non blocked account', async function () { + this.timeout(20000) + + { + const comment = { server: servers[1], token: userToken2, videoUUID: videoUUID1, text: 'displayed comment' } + await checkCommentNotification(servers[0], comment, 'presence') + } + + { + const comment = { + server: servers[0], + token: userToken1, + videoUUID: videoUUID2, + text: 'hello @root@' + servers[0].host + } + await checkCommentNotification(servers[0], comment, 'presence') + } + }) + }) + + describe('When managing server blocklist', function () { + + it('Should list all videos', function () { + return checkAllVideos(servers[0], servers[0].accessToken) + }) + + it('Should list the comments', function () { + return checkAllComments(servers[0], servers[0].accessToken, videoUUID1) + }) + + it('Should block a remote server', async function () { + await command.addToMyBlocklist({ server: '' + servers[1].host }) + }) + + it('Should hide its videos', async function () { + const { data } = await servers[0].videos.listWithToken() + + expect(data).to.have.lengthOf(3) + + const v1 = data.find(v => v.name === 'video user 2') + const v2 = data.find(v => v.name === 'video server 2') + + expect(v1).to.be.undefined + expect(v2).to.be.undefined + }) + + it('Should list all the videos with another user', async function () { + return checkAllVideos(servers[0], userToken1) + }) + + it('Should hide its comments', async function () { + const { id } = await commentsCommand[1].createThread({ token: userToken2, videoId: videoUUID1, text: 'hidden comment 2' }) + + await waitJobs(servers) + + await checkAllComments(servers[0], servers[0].accessToken, videoUUID1) + + await commentsCommand[1].delete({ token: userToken2, videoId: videoUUID1, commentId: id }) + }) + + it('Should not have notifications from blocked server', async function () { + this.timeout(20000) + + { + const comment = { server: servers[1], token: userToken2, videoUUID: videoUUID1, text: 'hidden comment' } + await checkCommentNotification(servers[0], comment, 'absence') + } + + { + const comment = { + server: servers[1], + token: userToken2, + videoUUID: videoUUID1, + text: 'hello @root@' + servers[0].host + } + await checkCommentNotification(servers[0], comment, 'absence') + } + }) + + it('Should list blocked servers', async function () { + const body = await command.listMyServerBlocklist({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(1) + + const block = body.data[0] + expect(block.byAccount.displayName).to.equal('root') + expect(block.byAccount.name).to.equal('root') + expect(block.blockedServer.host).to.equal('' + servers[1].host) + }) + + it('Should search blocked servers', async function () { + const body = await command.listMyServerBlocklist({ start: 0, count: 10, search: servers[1].host }) + expect(body.total).to.equal(1) + + expect(body.data[0].blockedServer.host).to.equal(servers[1].host) + }) + + it('Should get blocklist status', async function () { + const blockedServer = servers[1].host + const notBlockedServer = 'example.com' + + { + const status = await command.getStatus({ hosts: [ blockedServer, notBlockedServer ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(0) + + expect(Object.keys(status.hosts)).to.have.lengthOf(2) + expect(status.hosts[blockedServer].blockedByUser).to.be.false + expect(status.hosts[blockedServer].blockedByServer).to.be.false + + expect(status.hosts[notBlockedServer].blockedByUser).to.be.false + expect(status.hosts[notBlockedServer].blockedByServer).to.be.false + } + + { + const status = await command.getStatus({ token: servers[0].accessToken, hosts: [ blockedServer, notBlockedServer ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(0) + + expect(Object.keys(status.hosts)).to.have.lengthOf(2) + expect(status.hosts[blockedServer].blockedByUser).to.be.true + expect(status.hosts[blockedServer].blockedByServer).to.be.false + + expect(status.hosts[notBlockedServer].blockedByUser).to.be.false + expect(status.hosts[notBlockedServer].blockedByServer).to.be.false + } + }) + + it('Should unblock the remote server', async function () { + await command.removeFromMyBlocklist({ server: '' + servers[1].host }) + }) + + it('Should display its videos', function () { + return checkAllVideos(servers[0], servers[0].accessToken) + }) + + it('Should display its comments', function () { + return checkAllComments(servers[0], servers[0].accessToken, videoUUID1) + }) + + it('Should have notification from unblocked server', async function () { + this.timeout(20000) + + { + const comment = { server: servers[1], token: userToken2, videoUUID: videoUUID1, text: 'displayed comment' } + await checkCommentNotification(servers[0], comment, 'presence') + } + + { + const comment = { + server: servers[1], + token: userToken2, + videoUUID: videoUUID1, + text: 'hello @root@' + servers[0].host + } + await checkCommentNotification(servers[0], comment, 'presence') + } + }) + }) + }) + + describe('Server blocklist', function () { + + describe('When managing account blocklist', function () { + it('Should list all videos', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + await checkAllVideos(servers[0], token) + } + }) + + it('Should list the comments', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + await checkAllComments(servers[0], token, videoUUID1) + } + }) + + it('Should block a remote account', async function () { + await command.addToServerBlocklist({ account: 'user2@' + servers[1].host }) + }) + + it('Should hide its videos', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + const { data } = await servers[0].videos.listWithToken({ token }) + + expect(data).to.have.lengthOf(4) + + const v = data.find(v => v.name === 'video user 2') + expect(v).to.be.undefined + } + }) + + it('Should block a local account', async function () { + await command.addToServerBlocklist({ account: 'user1' }) + }) + + it('Should hide its videos', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + const { data } = await servers[0].videos.listWithToken({ token }) + + expect(data).to.have.lengthOf(3) + + const v = data.find(v => v.name === 'video user 1') + expect(v).to.be.undefined + } + }) + + it('Should hide its comments', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + const { data } = await commentsCommand[0].listThreads({ videoId: videoUUID1, count: 20, sort: '-createdAt', token }) + const threads = data.filter(t => t.isDeleted === false) + + expect(threads).to.have.lengthOf(1) + expect(threads[0].totalReplies).to.equal(1) + + const t = threads.find(t => t.text === 'comment user 1') + expect(t).to.be.undefined + + for (const thread of threads) { + const tree = await commentsCommand[0].getThread({ videoId: videoUUID1, threadId: thread.id, token }) + expect(tree.children).to.have.lengthOf(0) + } + } + }) + + it('Should not have notification from blocked accounts by instance', async function () { + this.timeout(20000) + + { + const comment = { server: servers[0], token: userToken1, videoUUID: videoUUID1, text: 'hidden comment' } + await checkCommentNotification(servers[0], comment, 'absence') + } + + { + const comment = { + server: servers[1], + token: userToken2, + videoUUID: videoUUID1, + text: 'hello @root@' + servers[0].host + } + await checkCommentNotification(servers[0], comment, 'absence') + } + }) + + it('Should list blocked accounts', async function () { + { + const body = await command.listServerAccountBlocklist({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const block = body.data[0] + expect(block.byAccount.displayName).to.equal('peertube') + expect(block.byAccount.name).to.equal('peertube') + expect(block.blockedAccount.displayName).to.equal('user2') + expect(block.blockedAccount.name).to.equal('user2') + expect(block.blockedAccount.host).to.equal('' + servers[1].host) + } + + { + const body = await command.listServerAccountBlocklist({ start: 1, count: 2, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const block = body.data[0] + expect(block.byAccount.displayName).to.equal('peertube') + expect(block.byAccount.name).to.equal('peertube') + expect(block.blockedAccount.displayName).to.equal('user1') + expect(block.blockedAccount.name).to.equal('user1') + expect(block.blockedAccount.host).to.equal('' + servers[0].host) + } + }) + + it('Should search blocked accounts', async function () { + const body = await command.listServerAccountBlocklist({ start: 0, count: 10, search: 'user2' }) + expect(body.total).to.equal(1) + + expect(body.data[0].blockedAccount.name).to.equal('user2') + }) + + it('Should get blocked status', async function () { + const remoteHandle = 'user2@' + servers[1].host + const localHandle = 'user1@' + servers[0].host + const unknownHandle = 'user5@' + servers[0].host + + for (const token of [ undefined, servers[0].accessToken ]) { + const status = await command.getStatus({ token, accounts: [ localHandle, remoteHandle, unknownHandle ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(3) + + for (const handle of [ localHandle, remoteHandle ]) { + expect(status.accounts[handle].blockedByUser).to.be.false + expect(status.accounts[handle].blockedByServer).to.be.true + } + + expect(status.accounts[unknownHandle].blockedByUser).to.be.false + expect(status.accounts[unknownHandle].blockedByServer).to.be.false + + expect(Object.keys(status.hosts)).to.have.lengthOf(0) + } + }) + + it('Should unblock the remote account', async function () { + await command.removeFromServerBlocklist({ account: 'user2@' + servers[1].host }) + }) + + it('Should display its videos', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + const { data } = await servers[0].videos.listWithToken({ token }) + expect(data).to.have.lengthOf(4) + + const v = data.find(v => v.name === 'video user 2') + expect(v).not.to.be.undefined + } + }) + + it('Should unblock the local account', async function () { + await command.removeFromServerBlocklist({ account: 'user1' }) + }) + + it('Should display its comments', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + await checkAllComments(servers[0], token, videoUUID1) + } + }) + + it('Should have notifications from unblocked accounts', async function () { + this.timeout(20000) + + { + const comment = { server: servers[0], token: userToken1, videoUUID: videoUUID1, text: 'displayed comment' } + await checkCommentNotification(servers[0], comment, 'presence') + } + + { + const comment = { + server: servers[1], + token: userToken2, + videoUUID: videoUUID1, + text: 'hello @root@' + servers[0].host + } + await checkCommentNotification(servers[0], comment, 'presence') + } + }) + }) + + describe('When managing server blocklist', function () { + + it('Should list all videos', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + await checkAllVideos(servers[0], token) + } + }) + + it('Should list the comments', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + await checkAllComments(servers[0], token, videoUUID1) + } + }) + + it('Should block a remote server', async function () { + await command.addToServerBlocklist({ server: '' + servers[1].host }) + }) + + it('Should hide its videos', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + const requests = [ + servers[0].videos.list(), + servers[0].videos.listWithToken({ token }) + ] + + for (const req of requests) { + const { data } = await req + expect(data).to.have.lengthOf(3) + + const v1 = data.find(v => v.name === 'video user 2') + const v2 = data.find(v => v.name === 'video server 2') + + expect(v1).to.be.undefined + expect(v2).to.be.undefined + } + } + }) + + it('Should hide its comments', async function () { + const { id } = await commentsCommand[1].createThread({ token: userToken2, videoId: videoUUID1, text: 'hidden comment 2' }) + + await waitJobs(servers) + + await checkAllComments(servers[0], servers[0].accessToken, videoUUID1) + + await commentsCommand[1].delete({ token: userToken2, videoId: videoUUID1, commentId: id }) + }) + + it('Should not have notification from blocked instances by instance', async function () { + this.timeout(50000) + + { + const comment = { server: servers[1], token: userToken2, videoUUID: videoUUID1, text: 'hidden comment' } + await checkCommentNotification(servers[0], comment, 'absence') + } + + { + const comment = { + server: servers[1], + token: userToken2, + videoUUID: videoUUID1, + text: 'hello @root@' + servers[0].host + } + await checkCommentNotification(servers[0], comment, 'absence') + } + + { + const now = new Date() + await servers[1].follows.unfollow({ target: servers[0] }) + await waitJobs(servers) + await servers[1].follows.follow({ hosts: [ servers[0].host ] }) + + await waitJobs(servers) + + const { data } = await servers[0].notifications.list({ start: 0, count: 30 }) + const commentNotifications = data.filter(n => { + return n.type === UserNotificationType.NEW_INSTANCE_FOLLOWER && n.createdAt >= now.toISOString() + }) + + expect(commentNotifications).to.have.lengthOf(0) + } + }) + + it('Should list blocked servers', async function () { + const body = await command.listServerServerBlocklist({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(1) + + const block = body.data[0] + expect(block.byAccount.displayName).to.equal('peertube') + expect(block.byAccount.name).to.equal('peertube') + expect(block.blockedServer.host).to.equal('' + servers[1].host) + }) + + it('Should search blocked servers', async function () { + const body = await command.listServerServerBlocklist({ start: 0, count: 10, search: servers[1].host }) + expect(body.total).to.equal(1) + + expect(body.data[0].blockedServer.host).to.equal(servers[1].host) + }) + + it('Should get blocklist status', async function () { + const blockedServer = servers[1].host + const notBlockedServer = 'example.com' + + for (const token of [ undefined, servers[0].accessToken ]) { + const status = await command.getStatus({ token, hosts: [ blockedServer, notBlockedServer ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(0) + + expect(Object.keys(status.hosts)).to.have.lengthOf(2) + expect(status.hosts[blockedServer].blockedByUser).to.be.false + expect(status.hosts[blockedServer].blockedByServer).to.be.true + + expect(status.hosts[notBlockedServer].blockedByUser).to.be.false + expect(status.hosts[notBlockedServer].blockedByServer).to.be.false + } + }) + + it('Should unblock the remote server', async function () { + await command.removeFromServerBlocklist({ server: '' + servers[1].host }) + }) + + it('Should list all videos', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + await checkAllVideos(servers[0], token) + } + }) + + it('Should list the comments', async function () { + for (const token of [ userModeratorToken, servers[0].accessToken ]) { + await checkAllComments(servers[0], token, videoUUID1) + } + }) + + it('Should have notification from unblocked instances', async function () { + this.timeout(50000) + + { + const comment = { server: servers[1], token: userToken2, videoUUID: videoUUID1, text: 'displayed comment' } + await checkCommentNotification(servers[0], comment, 'presence') + } + + { + const comment = { + server: servers[1], + token: userToken2, + videoUUID: videoUUID1, + text: 'hello @root@' + servers[0].host + } + await checkCommentNotification(servers[0], comment, 'presence') + } + + { + const now = new Date() + await servers[1].follows.unfollow({ target: servers[0] }) + await waitJobs(servers) + await servers[1].follows.follow({ hosts: [ servers[0].host ] }) + + await waitJobs(servers) + + const { data } = await servers[0].notifications.list({ start: 0, count: 30 }) + const commentNotifications = data.filter(n => { + return n.type === UserNotificationType.NEW_INSTANCE_FOLLOWER && n.createdAt >= now.toISOString() + }) + + expect(commentNotifications).to.have.lengthOf(1) + } + }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/moderation/index.ts b/packages/tests/src/api/moderation/index.ts new file mode 100644 index 000000000..e3794d01e --- /dev/null +++ b/packages/tests/src/api/moderation/index.ts @@ -0,0 +1,4 @@ +export * from './abuses.js' +export * from './blocklist-notification.js' +export * from './blocklist.js' +export * from './video-blacklist.js' diff --git a/packages/tests/src/api/moderation/video-blacklist.ts b/packages/tests/src/api/moderation/video-blacklist.ts new file mode 100644 index 000000000..341dadad0 --- /dev/null +++ b/packages/tests/src/api/moderation/video-blacklist.ts @@ -0,0 +1,414 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { sortObjectComparator } from '@peertube/peertube-core-utils' +import { UserAdminFlag, UserRole, VideoBlacklist, VideoBlacklistType } from '@peertube/peertube-models' +import { + BlacklistCommand, + cleanupTests, + createMultipleServers, + doubleFollow, + killallServers, + PeerTubeServer, + setAccessTokensToServers, + setDefaultChannelAvatar, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video blacklist', function () { + let servers: PeerTubeServer[] = [] + let videoId: number + let command: BlacklistCommand + + async function blacklistVideosOnServer (server: PeerTubeServer) { + const { data } = await server.videos.list() + + for (const video of data) { + await server.blacklist.add({ videoId: video.id, reason: 'super reason' }) + } + } + + before(async function () { + this.timeout(120000) + + // Run servers + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + await setDefaultChannelAvatar(servers[0]) + + // Upload 2 videos on server 2 + await servers[1].videos.upload({ attributes: { name: 'My 1st video', description: 'A video on server 2' } }) + await servers[1].videos.upload({ attributes: { name: 'My 2nd video', description: 'A video on server 2' } }) + + // Wait videos propagation, server 2 has transcoding enabled + await waitJobs(servers) + + command = servers[0].blacklist + + // Blacklist the two videos on server 1 + await blacklistVideosOnServer(servers[0]) + }) + + describe('When listing/searching videos', function () { + + it('Should not have the video blacklisted in videos list/search on server 1', async function () { + { + const { total, data } = await servers[0].videos.list() + + expect(total).to.equal(0) + expect(data).to.be.an('array') + expect(data.length).to.equal(0) + } + + { + const body = await servers[0].search.searchVideos({ search: 'video' }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + }) + + it('Should have the blacklisted video in videos list/search on server 2', async function () { + { + const { total, data } = await servers[1].videos.list() + + expect(total).to.equal(2) + expect(data).to.be.an('array') + expect(data.length).to.equal(2) + } + + { + const body = await servers[1].search.searchVideos({ search: 'video' }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(2) + } + }) + }) + + describe('When listing manually blacklisted videos', function () { + it('Should display all the blacklisted videos', async function () { + const body = await command.list() + expect(body.total).to.equal(2) + + const blacklistedVideos = body.data + expect(blacklistedVideos).to.be.an('array') + expect(blacklistedVideos.length).to.equal(2) + + for (const blacklistedVideo of blacklistedVideos) { + expect(blacklistedVideo.reason).to.equal('super reason') + videoId = blacklistedVideo.video.id + } + }) + + it('Should display all the blacklisted videos when applying manual type filter', async function () { + const body = await command.list({ type: VideoBlacklistType.MANUAL }) + expect(body.total).to.equal(2) + + const blacklistedVideos = body.data + expect(blacklistedVideos).to.be.an('array') + expect(blacklistedVideos.length).to.equal(2) + }) + + it('Should display nothing when applying automatic type filter', async function () { + const body = await command.list({ type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED }) + expect(body.total).to.equal(0) + + const blacklistedVideos = body.data + expect(blacklistedVideos).to.be.an('array') + expect(blacklistedVideos.length).to.equal(0) + }) + + it('Should get the correct sort when sorting by descending id', async function () { + const body = await command.list({ sort: '-id' }) + expect(body.total).to.equal(2) + + const blacklistedVideos = body.data + expect(blacklistedVideos).to.be.an('array') + expect(blacklistedVideos.length).to.equal(2) + + const result = [ ...body.data ].sort(sortObjectComparator('id', 'desc')) + expect(blacklistedVideos).to.deep.equal(result) + }) + + it('Should get the correct sort when sorting by descending video name', async function () { + const body = await command.list({ sort: '-name' }) + expect(body.total).to.equal(2) + + const blacklistedVideos = body.data + expect(blacklistedVideos).to.be.an('array') + expect(blacklistedVideos.length).to.equal(2) + + const result = [ ...body.data ].sort(sortObjectComparator('name', 'desc')) + expect(blacklistedVideos).to.deep.equal(result) + }) + + it('Should get the correct sort when sorting by ascending creation date', async function () { + const body = await command.list({ sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const blacklistedVideos = body.data + expect(blacklistedVideos).to.be.an('array') + expect(blacklistedVideos.length).to.equal(2) + + const result = [ ...body.data ].sort(sortObjectComparator('createdAt', 'asc')) + expect(blacklistedVideos).to.deep.equal(result) + }) + }) + + describe('When updating blacklisted videos', function () { + it('Should change the reason', async function () { + await command.update({ videoId, reason: 'my super reason updated' }) + + const body = await command.list({ sort: '-name' }) + const video = body.data.find(b => b.video.id === videoId) + + expect(video.reason).to.equal('my super reason updated') + }) + }) + + describe('When listing my videos', function () { + it('Should display blacklisted videos', async function () { + await blacklistVideosOnServer(servers[1]) + + const { total, data } = await servers[1].videos.listMyVideos() + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + for (const video of data) { + expect(video.blacklisted).to.be.true + expect(video.blacklistedReason).to.equal('super reason') + } + }) + }) + + describe('When removing a blacklisted video', function () { + let videoToRemove: VideoBlacklist + let blacklist = [] + + it('Should not have any video in videos list on server 1', async function () { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(0) + expect(data).to.be.an('array') + expect(data.length).to.equal(0) + }) + + it('Should remove a video from the blacklist on server 1', async function () { + // Get one video in the blacklist + const body = await command.list({ sort: '-name' }) + videoToRemove = body.data[0] + blacklist = body.data.slice(1) + + // Remove it + await command.remove({ videoId: videoToRemove.video.id }) + }) + + it('Should have the ex-blacklisted video in videos list on server 1', async function () { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(1) + + expect(data).to.be.an('array') + expect(data.length).to.equal(1) + + expect(data[0].name).to.equal(videoToRemove.video.name) + expect(data[0].id).to.equal(videoToRemove.video.id) + }) + + it('Should not have the ex-blacklisted video in videos blacklist list on server 1', async function () { + const body = await command.list({ sort: '-name' }) + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.be.an('array') + expect(videos.length).to.equal(1) + expect(videos).to.deep.equal(blacklist) + }) + }) + + describe('When blacklisting local videos', function () { + let video3UUID: string + let video4UUID: string + + before(async function () { + { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'Video 3' } }) + video3UUID = uuid + } + { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'Video 4' } }) + video4UUID = uuid + } + + await waitJobs(servers) + }) + + it('Should blacklist video 3 and keep it federated', async function () { + await command.add({ videoId: video3UUID, reason: 'super reason', unfederate: false }) + + await waitJobs(servers) + + { + const { data } = await servers[0].videos.list() + expect(data.find(v => v.uuid === video3UUID)).to.be.undefined + } + + { + const { data } = await servers[1].videos.list() + expect(data.find(v => v.uuid === video3UUID)).to.not.be.undefined + } + }) + + it('Should unfederate the video', async function () { + await command.add({ videoId: video4UUID, reason: 'super reason', unfederate: true }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + expect(data.find(v => v.uuid === video4UUID)).to.be.undefined + } + }) + + it('Should have the video unfederated even after an Update AP message', async function () { + await servers[0].videos.update({ id: video4UUID, attributes: { description: 'super description' } }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + expect(data.find(v => v.uuid === video4UUID)).to.be.undefined + } + }) + + it('Should have the correct video blacklist unfederate attribute', async function () { + const body = await command.list({ sort: 'createdAt' }) + + const blacklistedVideos = body.data + const video3Blacklisted = blacklistedVideos.find(b => b.video.uuid === video3UUID) + const video4Blacklisted = blacklistedVideos.find(b => b.video.uuid === video4UUID) + + expect(video3Blacklisted.unfederated).to.be.false + expect(video4Blacklisted.unfederated).to.be.true + }) + + it('Should remove the video from blacklist and refederate the video', async function () { + await command.remove({ videoId: video4UUID }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + expect(data.find(v => v.uuid === video4UUID)).to.not.be.undefined + } + }) + + }) + + describe('When auto blacklist videos', function () { + let userWithoutFlag: string + let userWithFlag: string + let channelOfUserWithoutFlag: number + + before(async function () { + this.timeout(20000) + + await killallServers([ servers[0] ]) + + const config = { + auto_blacklist: { + videos: { + of_users: { + enabled: true + } + } + } + } + await servers[0].run(config) + + { + const user = { username: 'user_without_flag', password: 'password' } + await servers[0].users.create({ + username: user.username, + adminFlags: UserAdminFlag.NONE, + password: user.password, + role: UserRole.USER + }) + + userWithoutFlag = await servers[0].login.getAccessToken(user) + + const { videoChannels } = await servers[0].users.getMyInfo({ token: userWithoutFlag }) + channelOfUserWithoutFlag = videoChannels[0].id + } + + { + const user = { username: 'user_with_flag', password: 'password' } + await servers[0].users.create({ + username: user.username, + adminFlags: UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST, + password: user.password, + role: UserRole.USER + }) + + userWithFlag = await servers[0].login.getAccessToken(user) + } + + await waitJobs(servers) + }) + + it('Should auto blacklist a video on upload', async function () { + await servers[0].videos.upload({ token: userWithoutFlag, attributes: { name: 'blacklisted' } }) + + const body = await command.list({ type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED }) + expect(body.total).to.equal(1) + expect(body.data[0].video.name).to.equal('blacklisted') + }) + + it('Should auto blacklist a video on URL import', async function () { + this.timeout(15000) + + const attributes = { + targetUrl: FIXTURE_URLS.goodVideo, + name: 'URL import', + channelId: channelOfUserWithoutFlag + } + await servers[0].imports.importVideo({ token: userWithoutFlag, attributes }) + + const body = await command.list({ sort: 'createdAt', type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED }) + expect(body.total).to.equal(2) + expect(body.data[1].video.name).to.equal('URL import') + }) + + it('Should auto blacklist a video on torrent import', async function () { + const attributes = { + magnetUri: FIXTURE_URLS.magnet, + name: 'Torrent import', + channelId: channelOfUserWithoutFlag + } + await servers[0].imports.importVideo({ token: userWithoutFlag, attributes }) + + const body = await command.list({ sort: 'createdAt', type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED }) + expect(body.total).to.equal(3) + expect(body.data[2].video.name).to.equal('Torrent import') + }) + + it('Should not auto blacklist a video on upload if the user has the bypass blacklist flag', async function () { + await servers[0].videos.upload({ token: userWithFlag, attributes: { name: 'not blacklisted' } }) + + const body = await command.list({ type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED }) + expect(body.total).to.equal(3) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/notifications/admin-notifications.ts b/packages/tests/src/api/notifications/admin-notifications.ts new file mode 100644 index 000000000..2186dc55a --- /dev/null +++ b/packages/tests/src/api/notifications/admin-notifications.ts @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { PluginType, UserNotification, UserNotificationType } from '@peertube/peertube-models' +import { cleanupTests, PeerTubeServer } from '@peertube/peertube-server-commands' +import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' +import { MockJoinPeerTubeVersions } from '@tests/shared/mock-servers/mock-joinpeertube-versions.js' +import { CheckerBaseParams, prepareNotificationsTest, checkNewPeerTubeVersion, checkNewPluginVersion } from '@tests/shared/notifications.js' +import { SQLCommand } from '@tests/shared/sql-command.js' + +describe('Test admin notifications', function () { + let server: PeerTubeServer + let sqlCommand: SQLCommand + let userNotifications: UserNotification[] = [] + let adminNotifications: UserNotification[] = [] + let emails: object[] = [] + let baseParams: CheckerBaseParams + let joinPeerTubeServer: MockJoinPeerTubeVersions + + before(async function () { + this.timeout(120000) + + joinPeerTubeServer = new MockJoinPeerTubeVersions() + const port = await joinPeerTubeServer.initialize() + + const config = { + peertube: { + check_latest_version: { + enabled: true, + url: `http://127.0.0.1:${port}/versions.json` + } + }, + plugins: { + index: { + enabled: true, + check_latest_versions_interval: '3 seconds' + } + } + } + + const res = await prepareNotificationsTest(1, config) + emails = res.emails + server = res.servers[0] + + userNotifications = res.userNotifications + adminNotifications = res.adminNotifications + + baseParams = { + server, + emails, + socketNotifications: adminNotifications, + token: server.accessToken + } + + await server.plugins.install({ npmName: 'peertube-plugin-hello-world' }) + await server.plugins.install({ npmName: 'peertube-theme-background-red' }) + + sqlCommand = new SQLCommand(server) + }) + + describe('Latest PeerTube version notification', function () { + + it('Should not send a notification to admins if there is no new version', async function () { + this.timeout(30000) + + joinPeerTubeServer.setLatestVersion('1.4.2') + + await wait(3000) + await checkNewPeerTubeVersion({ ...baseParams, latestVersion: '1.4.2', checkType: 'absence' }) + }) + + it('Should send a notification to admins on new version', async function () { + this.timeout(30000) + + joinPeerTubeServer.setLatestVersion('15.4.2') + + await wait(3000) + await checkNewPeerTubeVersion({ ...baseParams, latestVersion: '15.4.2', checkType: 'presence' }) + }) + + it('Should not send the same notification to admins', async function () { + this.timeout(30000) + + await wait(3000) + expect(adminNotifications.filter(n => n.type === UserNotificationType.NEW_PEERTUBE_VERSION)).to.have.lengthOf(1) + }) + + it('Should not have sent a notification to users', async function () { + this.timeout(30000) + + expect(userNotifications.filter(n => n.type === UserNotificationType.NEW_PEERTUBE_VERSION)).to.have.lengthOf(0) + }) + + it('Should send a new notification after a new release', async function () { + this.timeout(30000) + + joinPeerTubeServer.setLatestVersion('15.4.3') + + await wait(3000) + await checkNewPeerTubeVersion({ ...baseParams, latestVersion: '15.4.3', checkType: 'presence' }) + expect(adminNotifications.filter(n => n.type === UserNotificationType.NEW_PEERTUBE_VERSION)).to.have.lengthOf(2) + }) + }) + + describe('Latest plugin version notification', function () { + + it('Should not send a notification to admins if there is no new plugin version', async function () { + this.timeout(30000) + + await wait(6000) + await checkNewPluginVersion({ ...baseParams, pluginType: PluginType.PLUGIN, pluginName: 'hello-world', checkType: 'absence' }) + }) + + it('Should send a notification to admins on new plugin version', async function () { + this.timeout(30000) + + await sqlCommand.setPluginVersion('hello-world', '0.0.1') + await sqlCommand.setPluginLatestVersion('hello-world', '0.0.1') + await wait(6000) + + await checkNewPluginVersion({ ...baseParams, pluginType: PluginType.PLUGIN, pluginName: 'hello-world', checkType: 'presence' }) + }) + + it('Should not send the same notification to admins', async function () { + this.timeout(30000) + + await wait(6000) + + expect(adminNotifications.filter(n => n.type === UserNotificationType.NEW_PLUGIN_VERSION)).to.have.lengthOf(1) + }) + + it('Should not have sent a notification to users', async function () { + expect(userNotifications.filter(n => n.type === UserNotificationType.NEW_PLUGIN_VERSION)).to.have.lengthOf(0) + }) + + it('Should send a new notification after a new plugin release', async function () { + this.timeout(30000) + + await sqlCommand.setPluginVersion('hello-world', '0.0.1') + await sqlCommand.setPluginLatestVersion('hello-world', '0.0.1') + await wait(6000) + + expect(adminNotifications.filter(n => n.type === UserNotificationType.NEW_PEERTUBE_VERSION)).to.have.lengthOf(2) + }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await sqlCommand.cleanup() + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/notifications/comments-notifications.ts b/packages/tests/src/api/notifications/comments-notifications.ts new file mode 100644 index 000000000..5647d1286 --- /dev/null +++ b/packages/tests/src/api/notifications/comments-notifications.ts @@ -0,0 +1,300 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { UserNotification } from '@peertube/peertube-models' +import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' +import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' +import { prepareNotificationsTest, CheckerBaseParams, checkNewCommentOnMyVideo, checkCommentMention } from '@tests/shared/notifications.js' + +describe('Test comments notifications', function () { + let servers: PeerTubeServer[] = [] + let userToken: string + let userNotifications: UserNotification[] = [] + let emails: object[] = [] + + const commentText = '**hello** world,

what do you think about peertube?

' + const expectedHtml = 'hello world' + + ',

what do you think about peertube?' + + before(async function () { + this.timeout(120000) + + const res = await prepareNotificationsTest(2) + emails = res.emails + userToken = res.userAccessToken + servers = res.servers + userNotifications = res.userNotifications + }) + + describe('Comment on my video notifications', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[0], + emails, + socketNotifications: userNotifications, + token: userToken + } + }) + + it('Should not send a new comment notification after a comment on another video', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ attributes: { name: 'super video' } }) + + const created = await servers[0].comments.createThread({ videoId: uuid, text: 'comment' }) + const commentId = created.id + + await waitJobs(servers) + await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId: commentId, commentId, checkType: 'absence' }) + }) + + it('Should not send a new comment notification if I comment my own video', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'super video' } }) + + const created = await servers[0].comments.createThread({ token: userToken, videoId: uuid, text: 'comment' }) + const commentId = created.id + + await waitJobs(servers) + await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId: commentId, commentId, checkType: 'absence' }) + }) + + it('Should not send a new comment notification if the account is muted', async function () { + this.timeout(30000) + + await servers[0].blocklist.addToMyBlocklist({ token: userToken, account: 'root' }) + + const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'super video' } }) + + const created = await servers[0].comments.createThread({ videoId: uuid, text: 'comment' }) + const commentId = created.id + + await waitJobs(servers) + await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId: commentId, commentId, checkType: 'absence' }) + + await servers[0].blocklist.removeFromMyBlocklist({ token: userToken, account: 'root' }) + }) + + it('Should send a new comment notification after a local comment on my video', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'super video' } }) + + const created = await servers[0].comments.createThread({ videoId: uuid, text: 'comment' }) + const commentId = created.id + + await waitJobs(servers) + await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId: commentId, commentId, checkType: 'presence' }) + }) + + it('Should send a new comment notification after a remote comment on my video', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'super video' } }) + + await waitJobs(servers) + + await servers[1].comments.createThread({ videoId: uuid, text: 'comment' }) + + await waitJobs(servers) + + const { data } = await servers[0].comments.listThreads({ videoId: uuid }) + expect(data).to.have.lengthOf(1) + + const commentId = data[0].id + await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId: commentId, commentId, checkType: 'presence' }) + }) + + it('Should send a new comment notification after a local reply on my video', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'super video' } }) + + const { id: threadId } = await servers[0].comments.createThread({ videoId: uuid, text: 'comment' }) + + const { id: commentId } = await servers[0].comments.addReply({ videoId: uuid, toCommentId: threadId, text: 'reply' }) + + await waitJobs(servers) + await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId, commentId, checkType: 'presence' }) + }) + + it('Should send a new comment notification after a remote reply on my video', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'super video' } }) + await waitJobs(servers) + + { + const created = await servers[1].comments.createThread({ videoId: uuid, text: 'comment' }) + const threadId = created.id + await servers[1].comments.addReply({ videoId: uuid, toCommentId: threadId, text: 'reply' }) + } + + await waitJobs(servers) + + const { data } = await servers[0].comments.listThreads({ videoId: uuid }) + expect(data).to.have.lengthOf(1) + + const threadId = data[0].id + const tree = await servers[0].comments.getThread({ videoId: uuid, threadId }) + + expect(tree.children).to.have.lengthOf(1) + const commentId = tree.children[0].comment.id + + await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId, commentId, checkType: 'presence' }) + }) + + it('Should convert markdown in comment to html', async function () { + this.timeout(30000) + + const { uuid } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'cool video' } }) + + await servers[0].comments.createThread({ videoId: uuid, text: commentText }) + + await waitJobs(servers) + + const latestEmail = emails[emails.length - 1] + expect(latestEmail['html']).to.contain(expectedHtml) + }) + }) + + describe('Mention notifications', function () { + let baseParams: CheckerBaseParams + const byAccountDisplayName = 'super root name' + + before(async function () { + baseParams = { + server: servers[0], + emails, + socketNotifications: userNotifications, + token: userToken + } + + await servers[0].users.updateMe({ displayName: 'super root name' }) + await servers[1].users.updateMe({ displayName: 'super root 2 name' }) + }) + + it('Should not send a new mention comment notification if I mention the video owner', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'super video' } }) + + const { id: commentId } = await servers[0].comments.createThread({ videoId: uuid, text: '@user_1 hello' }) + + await waitJobs(servers) + await checkCommentMention({ ...baseParams, shortUUID, threadId: commentId, commentId, byAccountDisplayName, checkType: 'absence' }) + }) + + it('Should not send a new mention comment notification if I mention myself', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ attributes: { name: 'super video' } }) + + const { id: commentId } = await servers[0].comments.createThread({ token: userToken, videoId: uuid, text: '@user_1 hello' }) + + await waitJobs(servers) + await checkCommentMention({ ...baseParams, shortUUID, threadId: commentId, commentId, byAccountDisplayName, checkType: 'absence' }) + }) + + it('Should not send a new mention notification if the account is muted', async function () { + this.timeout(30000) + + await servers[0].blocklist.addToMyBlocklist({ token: userToken, account: 'root' }) + + const { uuid, shortUUID } = await servers[0].videos.upload({ attributes: { name: 'super video' } }) + + const { id: commentId } = await servers[0].comments.createThread({ videoId: uuid, text: '@user_1 hello' }) + + await waitJobs(servers) + await checkCommentMention({ ...baseParams, shortUUID, threadId: commentId, commentId, byAccountDisplayName, checkType: 'absence' }) + + await servers[0].blocklist.removeFromMyBlocklist({ token: userToken, account: 'root' }) + }) + + it('Should not send a new mention notification if the remote account mention a local account', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ attributes: { name: 'super video' } }) + + await waitJobs(servers) + const { id: threadId } = await servers[1].comments.createThread({ videoId: uuid, text: '@user_1 hello' }) + + await waitJobs(servers) + + const byAccountDisplayName = 'super root 2 name' + await checkCommentMention({ ...baseParams, shortUUID, threadId, commentId: threadId, byAccountDisplayName, checkType: 'absence' }) + }) + + it('Should send a new mention notification after local comments', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ attributes: { name: 'super video' } }) + + const { id: threadId } = await servers[0].comments.createThread({ videoId: uuid, text: '@user_1 hellotext: 1' }) + + await waitJobs(servers) + await checkCommentMention({ ...baseParams, shortUUID, threadId, commentId: threadId, byAccountDisplayName, checkType: 'presence' }) + + const { id: commentId } = await servers[0].comments.addReply({ videoId: uuid, toCommentId: threadId, text: 'hello 2 @user_1' }) + + await waitJobs(servers) + await checkCommentMention({ ...baseParams, shortUUID, commentId, threadId, byAccountDisplayName, checkType: 'presence' }) + }) + + it('Should send a new mention notification after remote comments', async function () { + this.timeout(30000) + + const { uuid, shortUUID } = await servers[0].videos.upload({ attributes: { name: 'super video' } }) + + await waitJobs(servers) + + const text1 = `hello @user_1@${servers[0].host} 1` + const { id: server2ThreadId } = await servers[1].comments.createThread({ videoId: uuid, text: text1 }) + + await waitJobs(servers) + + const { data } = await servers[0].comments.listThreads({ videoId: uuid }) + expect(data).to.have.lengthOf(1) + + const byAccountDisplayName = 'super root 2 name' + const threadId = data[0].id + await checkCommentMention({ ...baseParams, shortUUID, commentId: threadId, threadId, byAccountDisplayName, checkType: 'presence' }) + + const text2 = `@user_1@${servers[0].host} hello 2 @root@${servers[0].host}` + await servers[1].comments.addReply({ videoId: uuid, toCommentId: server2ThreadId, text: text2 }) + + await waitJobs(servers) + + const tree = await servers[0].comments.getThread({ videoId: uuid, threadId }) + + expect(tree.children).to.have.lengthOf(1) + const commentId = tree.children[0].comment.id + + await checkCommentMention({ ...baseParams, shortUUID, commentId, threadId, byAccountDisplayName, checkType: 'presence' }) + }) + + it('Should convert markdown in comment to html', async function () { + this.timeout(30000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'super video' } }) + + const { id: threadId } = await servers[0].comments.createThread({ videoId: uuid, text: '@user_1 hello 1' }) + + await servers[0].comments.addReply({ videoId: uuid, toCommentId: threadId, text: '@user_1 ' + commentText }) + + await waitJobs(servers) + + const latestEmail = emails[emails.length - 1] + expect(latestEmail['html']).to.contain(expectedHtml) + }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/notifications/index.ts b/packages/tests/src/api/notifications/index.ts new file mode 100644 index 000000000..d63d94182 --- /dev/null +++ b/packages/tests/src/api/notifications/index.ts @@ -0,0 +1,6 @@ +import './admin-notifications.js' +import './comments-notifications.js' +import './moderation-notifications.js' +import './notifications-api.js' +import './registrations-notifications.js' +import './user-notifications.js' diff --git a/packages/tests/src/api/notifications/moderation-notifications.ts b/packages/tests/src/api/notifications/moderation-notifications.ts new file mode 100644 index 000000000..493764882 --- /dev/null +++ b/packages/tests/src/api/notifications/moderation-notifications.ts @@ -0,0 +1,609 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { wait } from '@peertube/peertube-core-utils' +import { AbuseState, CustomConfig, UserNotification, UserRole, VideoPrivacy } from '@peertube/peertube-models' +import { buildUUID } from '@peertube/peertube-node-utils' +import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' +import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' +import { MockInstancesIndex } from '@tests/shared/mock-servers/mock-instances-index.js' +import { + prepareNotificationsTest, + CheckerBaseParams, + checkNewVideoAbuseForModerators, + checkNewCommentAbuseForModerators, + checkNewAccountAbuseForModerators, + checkAbuseStateChange, + checkNewAbuseMessage, + checkNewBlacklistOnMyVideo, + checkNewInstanceFollower, + checkAutoInstanceFollowing, + checkVideoAutoBlacklistForModerators, + checkVideoIsPublished, + checkNewVideoFromSubscription +} from '@tests/shared/notifications.js' + +describe('Test moderation notifications', function () { + let servers: PeerTubeServer[] = [] + let userToken1: string + let userToken2: string + + let userNotifications: UserNotification[] = [] + let adminNotifications: UserNotification[] = [] + let adminNotificationsServer2: UserNotification[] = [] + let emails: object[] = [] + + before(async function () { + this.timeout(120000) + + const res = await prepareNotificationsTest(3) + emails = res.emails + userToken1 = res.userAccessToken + servers = res.servers + userNotifications = res.userNotifications + adminNotifications = res.adminNotifications + adminNotificationsServer2 = res.adminNotificationsServer2 + + userToken2 = await servers[1].users.generateUserAndToken('user2', UserRole.USER) + }) + + describe('Abuse for moderators notification', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[0], + emails, + socketNotifications: adminNotifications, + token: servers[0].accessToken + } + }) + + it('Should not send a notification to moderators on local abuse reported by an admin', async function () { + this.timeout(50000) + + const name = 'video for abuse ' + buildUUID() + const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) + + await servers[0].abuses.report({ videoId: video.id, reason: 'super reason' }) + + await waitJobs(servers) + await checkNewVideoAbuseForModerators({ ...baseParams, shortUUID: video.shortUUID, videoName: name, checkType: 'absence' }) + }) + + it('Should send a notification to moderators on local video abuse', async function () { + this.timeout(50000) + + const name = 'video for abuse ' + buildUUID() + const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) + + await servers[0].abuses.report({ token: userToken1, videoId: video.id, reason: 'super reason' }) + + await waitJobs(servers) + await checkNewVideoAbuseForModerators({ ...baseParams, shortUUID: video.shortUUID, videoName: name, checkType: 'presence' }) + }) + + it('Should send a notification to moderators on remote video abuse', async function () { + this.timeout(50000) + + const name = 'video for abuse ' + buildUUID() + const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) + + await waitJobs(servers) + + const videoId = await servers[1].videos.getId({ uuid: video.uuid }) + await servers[1].abuses.report({ token: userToken2, videoId, reason: 'super reason' }) + + await waitJobs(servers) + await checkNewVideoAbuseForModerators({ ...baseParams, shortUUID: video.shortUUID, videoName: name, checkType: 'presence' }) + }) + + it('Should send a notification to moderators on local comment abuse', async function () { + this.timeout(50000) + + const name = 'video for abuse ' + buildUUID() + const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) + const comment = await servers[0].comments.createThread({ + token: userToken1, + videoId: video.id, + text: 'comment abuse ' + buildUUID() + }) + + await waitJobs(servers) + + await servers[0].abuses.report({ token: userToken1, commentId: comment.id, reason: 'super reason' }) + + await waitJobs(servers) + await checkNewCommentAbuseForModerators({ ...baseParams, shortUUID: video.shortUUID, videoName: name, checkType: 'presence' }) + }) + + it('Should send a notification to moderators on remote comment abuse', async function () { + this.timeout(50000) + + const name = 'video for abuse ' + buildUUID() + const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) + + await servers[0].comments.createThread({ + token: userToken1, + videoId: video.id, + text: 'comment abuse ' + buildUUID() + }) + + await waitJobs(servers) + + const { data } = await servers[1].comments.listThreads({ videoId: video.uuid }) + const commentId = data[0].id + await servers[1].abuses.report({ token: userToken2, commentId, reason: 'super reason' }) + + await waitJobs(servers) + await checkNewCommentAbuseForModerators({ ...baseParams, shortUUID: video.shortUUID, videoName: name, checkType: 'presence' }) + }) + + it('Should send a notification to moderators on local account abuse', async function () { + this.timeout(50000) + + const username = 'user' + new Date().getTime() + const { account } = await servers[0].users.create({ username, password: 'donald' }) + const accountId = account.id + + await servers[0].abuses.report({ token: userToken1, accountId, reason: 'super reason' }) + + await waitJobs(servers) + await checkNewAccountAbuseForModerators({ ...baseParams, displayName: username, checkType: 'presence' }) + }) + + it('Should send a notification to moderators on remote account abuse', async function () { + this.timeout(50000) + + const username = 'user' + new Date().getTime() + const tmpToken = await servers[0].users.generateUserAndToken(username) + await servers[0].videos.upload({ token: tmpToken, attributes: { name: 'super video' } }) + + await waitJobs(servers) + + const account = await servers[1].accounts.get({ accountName: username + '@' + servers[0].host }) + await servers[1].abuses.report({ token: userToken2, accountId: account.id, reason: 'super reason' }) + + await waitJobs(servers) + await checkNewAccountAbuseForModerators({ ...baseParams, displayName: username, checkType: 'presence' }) + }) + }) + + describe('Abuse state change notification', function () { + let baseParams: CheckerBaseParams + let abuseId: number + + before(async function () { + baseParams = { + server: servers[0], + emails, + socketNotifications: userNotifications, + token: userToken1 + } + + const name = 'abuse ' + buildUUID() + const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) + + const body = await servers[0].abuses.report({ token: userToken1, videoId: video.id, reason: 'super reason' }) + abuseId = body.abuse.id + }) + + it('Should send a notification to reporter if the abuse has been accepted', async function () { + this.timeout(30000) + + await servers[0].abuses.update({ abuseId, body: { state: AbuseState.ACCEPTED } }) + await waitJobs(servers) + + await checkAbuseStateChange({ ...baseParams, abuseId, state: AbuseState.ACCEPTED, checkType: 'presence' }) + }) + + it('Should send a notification to reporter if the abuse has been rejected', async function () { + this.timeout(30000) + + await servers[0].abuses.update({ abuseId, body: { state: AbuseState.REJECTED } }) + await waitJobs(servers) + + await checkAbuseStateChange({ ...baseParams, abuseId, state: AbuseState.REJECTED, checkType: 'presence' }) + }) + }) + + describe('New abuse message notification', function () { + let baseParamsUser: CheckerBaseParams + let baseParamsAdmin: CheckerBaseParams + let abuseId: number + let abuseId2: number + + before(async function () { + baseParamsUser = { + server: servers[0], + emails, + socketNotifications: userNotifications, + token: userToken1 + } + + baseParamsAdmin = { + server: servers[0], + emails, + socketNotifications: adminNotifications, + token: servers[0].accessToken + } + + const name = 'abuse ' + buildUUID() + const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) + + { + const body = await servers[0].abuses.report({ token: userToken1, videoId: video.id, reason: 'super reason' }) + abuseId = body.abuse.id + } + + { + const body = await servers[0].abuses.report({ token: userToken1, videoId: video.id, reason: 'super reason 2' }) + abuseId2 = body.abuse.id + } + }) + + it('Should send a notification to reporter on new message', async function () { + this.timeout(30000) + + const message = 'my super message to users' + await servers[0].abuses.addMessage({ abuseId, message }) + await waitJobs(servers) + + await checkNewAbuseMessage({ ...baseParamsUser, abuseId, message, toEmail: 'user_1@example.com', checkType: 'presence' }) + }) + + it('Should not send a notification to the admin if sent by the admin', async function () { + this.timeout(30000) + + const message = 'my super message that should not be sent to the admin' + await servers[0].abuses.addMessage({ abuseId, message }) + await waitJobs(servers) + + const toEmail = 'admin' + servers[0].internalServerNumber + '@example.com' + await checkNewAbuseMessage({ ...baseParamsAdmin, abuseId, message, toEmail, checkType: 'absence' }) + }) + + it('Should send a notification to moderators', async function () { + this.timeout(30000) + + const message = 'my super message to moderators' + await servers[0].abuses.addMessage({ token: userToken1, abuseId: abuseId2, message }) + await waitJobs(servers) + + const toEmail = 'admin' + servers[0].internalServerNumber + '@example.com' + await checkNewAbuseMessage({ ...baseParamsAdmin, abuseId: abuseId2, message, toEmail, checkType: 'presence' }) + }) + + it('Should not send a notification to reporter if sent by the reporter', async function () { + this.timeout(30000) + + const message = 'my super message that should not be sent to reporter' + await servers[0].abuses.addMessage({ token: userToken1, abuseId: abuseId2, message }) + await waitJobs(servers) + + const toEmail = 'user_1@example.com' + await checkNewAbuseMessage({ ...baseParamsUser, abuseId: abuseId2, message, toEmail, checkType: 'absence' }) + }) + }) + + describe('Video blacklist on my video', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[0], + emails, + socketNotifications: userNotifications, + token: userToken1 + } + }) + + it('Should send a notification to video owner on blacklist', async function () { + this.timeout(30000) + + const name = 'video for abuse ' + buildUUID() + const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) + + await servers[0].blacklist.add({ videoId: uuid }) + + await waitJobs(servers) + await checkNewBlacklistOnMyVideo({ ...baseParams, shortUUID, videoName: name, blacklistType: 'blacklist' }) + }) + + it('Should send a notification to video owner on unblacklist', async function () { + this.timeout(30000) + + const name = 'video for abuse ' + buildUUID() + const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) + + await servers[0].blacklist.add({ videoId: uuid }) + + await waitJobs(servers) + await servers[0].blacklist.remove({ videoId: uuid }) + await waitJobs(servers) + + await wait(500) + await checkNewBlacklistOnMyVideo({ ...baseParams, shortUUID, videoName: name, blacklistType: 'unblacklist' }) + }) + }) + + describe('New instance follows', function () { + const instanceIndexServer = new MockInstancesIndex() + let config: any + let baseParams: CheckerBaseParams + + before(async function () { + baseParams = { + server: servers[0], + emails, + socketNotifications: adminNotifications, + token: servers[0].accessToken + } + + const port = await instanceIndexServer.initialize() + instanceIndexServer.addInstance(servers[1].host) + + config = { + followings: { + instance: { + autoFollowIndex: { + indexUrl: `http://127.0.0.1:${port}/api/v1/instances/hosts`, + enabled: true + } + } + } + } + }) + + it('Should send a notification only to admin when there is a new instance follower', async function () { + this.timeout(60000) + + await servers[2].follows.follow({ hosts: [ servers[0].url ] }) + + await waitJobs(servers) + + await checkNewInstanceFollower({ ...baseParams, followerHost: servers[2].host, checkType: 'presence' }) + + const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } } + await checkNewInstanceFollower({ ...baseParams, ...userOverride, followerHost: servers[2].host, checkType: 'absence' }) + }) + + it('Should send a notification on auto follow back', async function () { + this.timeout(40000) + + await servers[2].follows.unfollow({ target: servers[0] }) + await waitJobs(servers) + + const config = { + followings: { + instance: { + autoFollowBack: { enabled: true } + } + } + } + await servers[0].config.updateCustomSubConfig({ newConfig: config }) + + await servers[2].follows.follow({ hosts: [ servers[0].url ] }) + + await waitJobs(servers) + + const followerHost = servers[0].host + const followingHost = servers[2].host + await checkAutoInstanceFollowing({ ...baseParams, followerHost, followingHost, checkType: 'presence' }) + + const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } } + await checkAutoInstanceFollowing({ ...baseParams, ...userOverride, followerHost, followingHost, checkType: 'absence' }) + + config.followings.instance.autoFollowBack.enabled = false + await servers[0].config.updateCustomSubConfig({ newConfig: config }) + await servers[0].follows.unfollow({ target: servers[2] }) + await servers[2].follows.unfollow({ target: servers[0] }) + }) + + it('Should send a notification on auto instances index follow', async function () { + this.timeout(30000) + await servers[0].follows.unfollow({ target: servers[1] }) + + await servers[0].config.updateCustomSubConfig({ newConfig: config }) + + await wait(5000) + await waitJobs(servers) + + const followerHost = servers[0].host + const followingHost = servers[1].host + await checkAutoInstanceFollowing({ ...baseParams, followerHost, followingHost, checkType: 'presence' }) + + config.followings.instance.autoFollowIndex.enabled = false + await servers[0].config.updateCustomSubConfig({ newConfig: config }) + await servers[0].follows.unfollow({ target: servers[1] }) + }) + }) + + describe('Video-related notifications when video auto-blacklist is enabled', function () { + let userBaseParams: CheckerBaseParams + let adminBaseParamsServer1: CheckerBaseParams + let adminBaseParamsServer2: CheckerBaseParams + let uuid: string + let shortUUID: string + let videoName: string + let currentCustomConfig: CustomConfig + + before(async function () { + + adminBaseParamsServer1 = { + server: servers[0], + emails, + socketNotifications: adminNotifications, + token: servers[0].accessToken + } + + adminBaseParamsServer2 = { + server: servers[1], + emails, + socketNotifications: adminNotificationsServer2, + token: servers[1].accessToken + } + + userBaseParams = { + server: servers[0], + emails, + socketNotifications: userNotifications, + token: userToken1 + } + + currentCustomConfig = await servers[0].config.getCustomConfig() + + const autoBlacklistTestsCustomConfig = { + ...currentCustomConfig, + + autoBlacklist: { + videos: { + ofUsers: { + enabled: true + } + } + } + } + + // enable transcoding otherwise own publish notification after transcoding not expected + autoBlacklistTestsCustomConfig.transcoding.enabled = true + await servers[0].config.updateCustomConfig({ newCustomConfig: autoBlacklistTestsCustomConfig }) + + await servers[0].subscriptions.add({ targetUri: 'user_1_channel@' + servers[0].host }) + await servers[1].subscriptions.add({ targetUri: 'user_1_channel@' + servers[0].host }) + }) + + it('Should send notification to moderators on new video with auto-blacklist', async function () { + this.timeout(120000) + + videoName = 'video with auto-blacklist ' + buildUUID() + const video = await servers[0].videos.upload({ token: userToken1, attributes: { name: videoName } }) + shortUUID = video.shortUUID + uuid = video.uuid + + await waitJobs(servers) + await checkVideoAutoBlacklistForModerators({ ...adminBaseParamsServer1, shortUUID, videoName, checkType: 'presence' }) + }) + + it('Should not send video publish notification if auto-blacklisted', async function () { + this.timeout(120000) + + await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' }) + }) + + it('Should not send a local user subscription notification if auto-blacklisted', async function () { + this.timeout(120000) + + await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' }) + }) + + it('Should not send a remote user subscription notification if auto-blacklisted', async function () { + await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'absence' }) + }) + + it('Should send video published and unblacklist after video unblacklisted', async function () { + this.timeout(120000) + + await servers[0].blacklist.remove({ videoId: uuid }) + + await waitJobs(servers) + + // FIXME: Can't test as two notifications sent to same user and util only checks last one + // One notification might be better anyways + // await checkNewBlacklistOnMyVideo(userBaseParams, videoUUID, videoName, 'unblacklist') + // await checkVideoIsPublished(userBaseParams, videoName, videoUUID, 'presence') + }) + + it('Should send a local user subscription notification after removed from blacklist', async function () { + this.timeout(120000) + + await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' }) + }) + + it('Should send a remote user subscription notification after removed from blacklist', async function () { + this.timeout(120000) + + await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' }) + }) + + it('Should send unblacklist but not published/subscription notes after unblacklisted if scheduled update pending', async function () { + this.timeout(120000) + + const updateAt = new Date(new Date().getTime() + 1000000) + + const name = 'video with auto-blacklist and future schedule ' + buildUUID() + + const attributes = { + name, + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: updateAt.toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + + const { shortUUID, uuid } = await servers[0].videos.upload({ token: userToken1, attributes }) + + await servers[0].blacklist.remove({ videoId: uuid }) + + await waitJobs(servers) + await checkNewBlacklistOnMyVideo({ ...userBaseParams, shortUUID, videoName: name, blacklistType: 'unblacklist' }) + + // FIXME: Can't test absence as two notifications sent to same user and util only checks last one + // One notification might be better anyways + // await checkVideoIsPublished(userBaseParams, name, uuid, 'absence') + + await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName: name, shortUUID, checkType: 'absence' }) + await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName: name, shortUUID, checkType: 'absence' }) + }) + + it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () { + this.timeout(120000) + + // In 2 seconds + const updateAt = new Date(new Date().getTime() + 2000) + + const name = 'video with schedule done and still auto-blacklisted ' + buildUUID() + + const attributes = { + name, + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: updateAt.toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + + const { shortUUID } = await servers[0].videos.upload({ token: userToken1, attributes }) + + await wait(6000) + await checkVideoIsPublished({ ...userBaseParams, videoName: name, shortUUID, checkType: 'absence' }) + await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName: name, shortUUID, checkType: 'absence' }) + await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName: name, shortUUID, checkType: 'absence' }) + }) + + it('Should not send a notification to moderators on new video without auto-blacklist', async function () { + this.timeout(120000) + + const name = 'video without auto-blacklist ' + buildUUID() + + // admin with blacklist right will not be auto-blacklisted + const { shortUUID } = await servers[0].videos.upload({ attributes: { name } }) + + await waitJobs(servers) + await checkVideoAutoBlacklistForModerators({ ...adminBaseParamsServer1, shortUUID, videoName: name, checkType: 'absence' }) + }) + + after(async () => { + await servers[0].config.updateCustomConfig({ newCustomConfig: currentCustomConfig }) + + await servers[0].subscriptions.remove({ uri: 'user_1_channel@' + servers[0].host }) + await servers[1].subscriptions.remove({ uri: 'user_1_channel@' + servers[0].host }) + }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/notifications/notifications-api.ts b/packages/tests/src/api/notifications/notifications-api.ts new file mode 100644 index 000000000..1c7461553 --- /dev/null +++ b/packages/tests/src/api/notifications/notifications-api.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { UserNotification, UserNotificationSettingValue } from '@peertube/peertube-models' +import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' +import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' +import { + prepareNotificationsTest, + CheckerBaseParams, + getAllNotificationsSettings, + checkNewVideoFromSubscription +} from '@tests/shared/notifications.js' + +describe('Test notifications API', function () { + let server: PeerTubeServer + let userNotifications: UserNotification[] = [] + let userToken: string + let emails: object[] = [] + + before(async function () { + this.timeout(120000) + + const res = await prepareNotificationsTest(1) + emails = res.emails + userToken = res.userAccessToken + userNotifications = res.userNotifications + server = res.servers[0] + + await server.subscriptions.add({ token: userToken, targetUri: 'root_channel@' + server.host }) + + for (let i = 0; i < 10; i++) { + await server.videos.randomUpload({ wait: false }) + } + + await waitJobs([ server ]) + }) + + describe('Notification list & count', function () { + + it('Should correctly list notifications', async function () { + const { data, total } = await server.notifications.list({ token: userToken, start: 0, count: 2 }) + + expect(data).to.have.lengthOf(2) + expect(total).to.equal(10) + }) + }) + + describe('Mark as read', function () { + + it('Should mark as read some notifications', async function () { + const { data } = await server.notifications.list({ token: userToken, start: 2, count: 3 }) + const ids = data.map(n => n.id) + + await server.notifications.markAsRead({ token: userToken, ids }) + }) + + it('Should have the notifications marked as read', async function () { + const { data } = await server.notifications.list({ token: userToken, start: 0, count: 10 }) + + expect(data[0].read).to.be.false + expect(data[1].read).to.be.false + expect(data[2].read).to.be.true + expect(data[3].read).to.be.true + expect(data[4].read).to.be.true + expect(data[5].read).to.be.false + }) + + it('Should only list read notifications', async function () { + const { data } = await server.notifications.list({ token: userToken, start: 0, count: 10, unread: false }) + + for (const notification of data) { + expect(notification.read).to.be.true + } + }) + + it('Should only list unread notifications', async function () { + const { data } = await server.notifications.list({ token: userToken, start: 0, count: 10, unread: true }) + + for (const notification of data) { + expect(notification.read).to.be.false + } + }) + + it('Should mark as read all notifications', async function () { + await server.notifications.markAsReadAll({ token: userToken }) + + const body = await server.notifications.list({ token: userToken, start: 0, count: 10, unread: true }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + }) + + describe('Notification settings', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server, + emails, + socketNotifications: userNotifications, + token: userToken + } + }) + + it('Should not have notifications', async function () { + this.timeout(40000) + + await server.notifications.updateMySettings({ + token: userToken, + settings: { ...getAllNotificationsSettings(), newVideoFromSubscription: UserNotificationSettingValue.NONE } + }) + + { + const info = await server.users.getMyInfo({ token: userToken }) + expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.NONE) + } + + const { name, shortUUID } = await server.videos.randomUpload() + + const check = { web: true, mail: true } + await checkNewVideoFromSubscription({ ...baseParams, check, videoName: name, shortUUID, checkType: 'absence' }) + }) + + it('Should only have web notifications', async function () { + this.timeout(20000) + + await server.notifications.updateMySettings({ + token: userToken, + settings: { ...getAllNotificationsSettings(), newVideoFromSubscription: UserNotificationSettingValue.WEB } + }) + + { + const info = await server.users.getMyInfo({ token: userToken }) + expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB) + } + + const { name, shortUUID } = await server.videos.randomUpload() + + { + const check = { mail: true, web: false } + await checkNewVideoFromSubscription({ ...baseParams, check, videoName: name, shortUUID, checkType: 'absence' }) + } + + { + const check = { mail: false, web: true } + await checkNewVideoFromSubscription({ ...baseParams, check, videoName: name, shortUUID, checkType: 'presence' }) + } + }) + + it('Should only have mail notifications', async function () { + this.timeout(20000) + + await server.notifications.updateMySettings({ + token: userToken, + settings: { ...getAllNotificationsSettings(), newVideoFromSubscription: UserNotificationSettingValue.EMAIL } + }) + + { + const info = await server.users.getMyInfo({ token: userToken }) + expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.EMAIL) + } + + const { name, shortUUID } = await server.videos.randomUpload() + + { + const check = { mail: false, web: true } + await checkNewVideoFromSubscription({ ...baseParams, check, videoName: name, shortUUID, checkType: 'absence' }) + } + + { + const check = { mail: true, web: false } + await checkNewVideoFromSubscription({ ...baseParams, check, videoName: name, shortUUID, checkType: 'presence' }) + } + }) + + it('Should have email and web notifications', async function () { + this.timeout(20000) + + await server.notifications.updateMySettings({ + token: userToken, + settings: { + ...getAllNotificationsSettings(), + newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + } + }) + + { + const info = await server.users.getMyInfo({ token: userToken }) + expect(info.notificationSettings.newVideoFromSubscription).to.equal( + UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + ) + } + + const { name, shortUUID } = await server.videos.randomUpload() + + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/notifications/registrations-notifications.ts b/packages/tests/src/api/notifications/registrations-notifications.ts new file mode 100644 index 000000000..1f166cb36 --- /dev/null +++ b/packages/tests/src/api/notifications/registrations-notifications.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { UserNotification } from '@peertube/peertube-models' +import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' +import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' +import { CheckerBaseParams, prepareNotificationsTest, checkUserRegistered, checkRegistrationRequest } from '@tests/shared/notifications.js' + +describe('Test registrations notifications', function () { + let server: PeerTubeServer + let userToken1: string + + let userNotifications: UserNotification[] = [] + let adminNotifications: UserNotification[] = [] + let emails: object[] = [] + + let baseParams: CheckerBaseParams + + before(async function () { + this.timeout(120000) + + const res = await prepareNotificationsTest(1) + + server = res.servers[0] + emails = res.emails + userToken1 = res.userAccessToken + adminNotifications = res.adminNotifications + userNotifications = res.userNotifications + + baseParams = { + server, + emails, + socketNotifications: adminNotifications, + token: server.accessToken + } + }) + + describe('New direct registration for moderators', function () { + + before(async function () { + await server.config.enableSignup(false) + }) + + it('Should send a notification only to moderators when a user registers on the instance', async function () { + this.timeout(50000) + + await server.registrations.register({ username: 'user_10' }) + + await waitJobs([ server ]) + + await checkUserRegistered({ ...baseParams, username: 'user_10', checkType: 'presence' }) + + const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } } + await checkUserRegistered({ ...baseParams, ...userOverride, username: 'user_10', checkType: 'absence' }) + }) + }) + + describe('New registration request for moderators', function () { + + before(async function () { + await server.config.enableSignup(true) + }) + + it('Should send a notification on new registration request', async function () { + this.timeout(50000) + + const registrationReason = 'my reason' + await server.registrations.requestRegistration({ username: 'user_11', registrationReason }) + + await waitJobs([ server ]) + + await checkRegistrationRequest({ ...baseParams, username: 'user_11', registrationReason, checkType: 'presence' }) + + const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } } + await checkRegistrationRequest({ ...baseParams, ...userOverride, username: 'user_11', registrationReason, checkType: 'absence' }) + }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/notifications/user-notifications.ts b/packages/tests/src/api/notifications/user-notifications.ts new file mode 100644 index 000000000..4c03cdb47 --- /dev/null +++ b/packages/tests/src/api/notifications/user-notifications.ts @@ -0,0 +1,574 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { UserNotification, UserNotificationType, VideoPrivacy, VideoStudioTask } from '@peertube/peertube-models' +import { buildUUID } from '@peertube/peertube-node-utils' +import { cleanupTests, findExternalSavedVideo, PeerTubeServer, stopFfmpeg, waitJobs } from '@peertube/peertube-server-commands' +import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' +import { + prepareNotificationsTest, + CheckerBaseParams, + checkNewVideoFromSubscription, + checkVideoIsPublished, + checkVideoStudioEditionIsFinished, + checkMyVideoImportIsFinished, + checkNewActorFollow +} from '@tests/shared/notifications.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { uploadRandomVideoOnServers } from '@tests/shared/videos.js' + +describe('Test user notifications', function () { + let servers: PeerTubeServer[] = [] + let userAccessToken: string + + let userNotifications: UserNotification[] = [] + let adminNotifications: UserNotification[] = [] + let adminNotificationsServer2: UserNotification[] = [] + let emails: object[] = [] + + let channelId: number + + before(async function () { + this.timeout(120000) + + const res = await prepareNotificationsTest(3) + emails = res.emails + userAccessToken = res.userAccessToken + servers = res.servers + userNotifications = res.userNotifications + adminNotifications = res.adminNotifications + adminNotificationsServer2 = res.adminNotificationsServer2 + channelId = res.channelId + }) + + describe('New video from my subscription notification', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[0], + emails, + socketNotifications: userNotifications, + token: userAccessToken + } + }) + + it('Should not send notifications if the user does not follow the video publisher', async function () { + this.timeout(50000) + + await uploadRandomVideoOnServers(servers, 1) + + const notification = await servers[0].notifications.getLatest({ token: userAccessToken }) + expect(notification).to.be.undefined + + expect(emails).to.have.lengthOf(0) + expect(userNotifications).to.have.lengthOf(0) + }) + + it('Should send a new video notification if the user follows the local video publisher', async function () { + this.timeout(15000) + + await servers[0].subscriptions.add({ token: userAccessToken, targetUri: 'root_channel@' + servers[0].host }) + await waitJobs(servers) + + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 1) + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + + it('Should send a new video notification from a remote account', async function () { + this.timeout(150000) // Server 2 has transcoding enabled + + await servers[0].subscriptions.add({ token: userAccessToken, targetUri: 'root_channel@' + servers[1].host }) + await waitJobs(servers) + + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 2) + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + + it('Should send a new video notification on a scheduled publication', async function () { + this.timeout(50000) + + // In 2 seconds + const updateAt = new Date(new Date().getTime() + 2000) + + const data = { + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: updateAt.toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 1, data) + + await wait(6000) + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + + it('Should send a new video notification on a remote scheduled publication', async function () { + this.timeout(100000) + + // In 2 seconds + const updateAt = new Date(new Date().getTime() + 2000) + + const data = { + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: updateAt.toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 2, data) + await waitJobs(servers) + + await wait(6000) + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + + it('Should not send a notification before the video is published', async function () { + this.timeout(150000) + + const updateAt = new Date(new Date().getTime() + 1000000) + + const data = { + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: updateAt.toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 1, data) + + await wait(6000) + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'absence' }) + }) + + it('Should send a new video notification when a video becomes public', async function () { + this.timeout(50000) + + const data = { privacy: VideoPrivacy.PRIVATE } + const { name, uuid, shortUUID } = await uploadRandomVideoOnServers(servers, 1, data) + + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'absence' }) + + await servers[0].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + + await waitJobs(servers) + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + + it('Should send a new video notification when a remote video becomes public', async function () { + this.timeout(120000) + + const data = { privacy: VideoPrivacy.PRIVATE } + const { name, uuid, shortUUID } = await uploadRandomVideoOnServers(servers, 2, data) + + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'absence' }) + + await servers[1].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + + await waitJobs(servers) + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + + it('Should not send a new video notification when a video becomes unlisted', async function () { + this.timeout(50000) + + const data = { privacy: VideoPrivacy.PRIVATE } + const { name, uuid, shortUUID } = await uploadRandomVideoOnServers(servers, 1, data) + + await servers[0].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.UNLISTED } }) + + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'absence' }) + }) + + it('Should not send a new video notification when a remote video becomes unlisted', async function () { + this.timeout(100000) + + const data = { privacy: VideoPrivacy.PRIVATE } + const { name, uuid, shortUUID } = await uploadRandomVideoOnServers(servers, 2, data) + + await servers[1].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.UNLISTED } }) + + await waitJobs(servers) + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID, checkType: 'absence' }) + }) + + it('Should send a new video notification after a video import', async function () { + this.timeout(100000) + + const name = 'video import ' + buildUUID() + + const attributes = { + name, + channelId, + privacy: VideoPrivacy.PUBLIC, + targetUrl: FIXTURE_URLS.goodVideo + } + const { video } = await servers[0].imports.importVideo({ attributes }) + + await waitJobs(servers) + + await checkNewVideoFromSubscription({ ...baseParams, videoName: name, shortUUID: video.shortUUID, checkType: 'presence' }) + }) + }) + + describe('My video is published', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[1], + emails, + socketNotifications: adminNotificationsServer2, + token: servers[1].accessToken + } + }) + + it('Should not send a notification if transcoding is not enabled', async function () { + this.timeout(50000) + + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 1) + await waitJobs(servers) + + await checkVideoIsPublished({ ...baseParams, videoName: name, shortUUID, checkType: 'absence' }) + }) + + it('Should not send a notification if the wait transcoding is false', async function () { + this.timeout(100_000) + + await uploadRandomVideoOnServers(servers, 2, { waitTranscoding: false }) + await waitJobs(servers) + + const notification = await servers[0].notifications.getLatest({ token: userAccessToken }) + if (notification) { + expect(notification.type).to.not.equal(UserNotificationType.MY_VIDEO_PUBLISHED) + } + }) + + it('Should send a notification even if the video is not transcoded in other resolutions', async function () { + this.timeout(100_000) + + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 2, { waitTranscoding: true, fixture: 'video_short_240p.mp4' }) + await waitJobs(servers) + + await checkVideoIsPublished({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + + it('Should send a notification with a transcoded video', async function () { + this.timeout(100_000) + + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 2, { waitTranscoding: true }) + await waitJobs(servers) + + await checkVideoIsPublished({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + + it('Should send a notification when an imported video is transcoded', async function () { + this.timeout(120000) + + const name = 'video import ' + buildUUID() + + const attributes = { + name, + channelId, + privacy: VideoPrivacy.PUBLIC, + targetUrl: FIXTURE_URLS.goodVideo, + waitTranscoding: true + } + const { video } = await servers[1].imports.importVideo({ attributes }) + + await waitJobs(servers) + await checkVideoIsPublished({ ...baseParams, videoName: name, shortUUID: video.shortUUID, checkType: 'presence' }) + }) + + it('Should send a notification when the scheduled update has been proceeded', async function () { + this.timeout(70000) + + // In 2 seconds + const updateAt = new Date(new Date().getTime() + 2000) + + const data = { + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: updateAt.toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 2, data) + + await wait(6000) + await checkVideoIsPublished({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + + it('Should not send a notification before the video is published', async function () { + this.timeout(150000) + + const updateAt = new Date(new Date().getTime() + 1000000) + + const data = { + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: updateAt.toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + const { name, shortUUID } = await uploadRandomVideoOnServers(servers, 2, data) + + await wait(6000) + await checkVideoIsPublished({ ...baseParams, videoName: name, shortUUID, checkType: 'absence' }) + }) + }) + + describe('My live replay is published', function () { + + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[1], + emails, + socketNotifications: adminNotificationsServer2, + token: servers[1].accessToken + } + }) + + it('Should send a notification is a live replay of a non permanent live is published', async function () { + this.timeout(120000) + + const { shortUUID } = await servers[1].live.create({ + fields: { + name: 'non permanent live', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[1].store.channel.id, + saveReplay: true, + replaySettings: { privacy: VideoPrivacy.PUBLIC }, + permanentLive: false + } + }) + + const ffmpegCommand = await servers[1].live.sendRTMPStreamInVideo({ videoId: shortUUID }) + + await waitJobs(servers) + await servers[1].live.waitUntilPublished({ videoId: shortUUID }) + + await stopFfmpeg(ffmpegCommand) + await servers[1].live.waitUntilReplacedByReplay({ videoId: shortUUID }) + + await waitJobs(servers) + await checkVideoIsPublished({ ...baseParams, videoName: 'non permanent live', shortUUID, checkType: 'presence' }) + }) + + it('Should send a notification is a live replay of a permanent live is published', async function () { + this.timeout(120000) + + const { shortUUID } = await servers[1].live.create({ + fields: { + name: 'permanent live', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[1].store.channel.id, + saveReplay: true, + replaySettings: { privacy: VideoPrivacy.PUBLIC }, + permanentLive: true + } + }) + + const ffmpegCommand = await servers[1].live.sendRTMPStreamInVideo({ videoId: shortUUID }) + + await waitJobs(servers) + await servers[1].live.waitUntilPublished({ videoId: shortUUID }) + + const liveDetails = await servers[1].videos.get({ id: shortUUID }) + + await stopFfmpeg(ffmpegCommand) + + await servers[1].live.waitUntilWaiting({ videoId: shortUUID }) + await waitJobs(servers) + + const video = await findExternalSavedVideo(servers[1], liveDetails) + expect(video).to.exist + + await checkVideoIsPublished({ ...baseParams, videoName: video.name, shortUUID: video.shortUUID, checkType: 'presence' }) + }) + }) + + describe('Video studio', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[1], + emails, + socketNotifications: adminNotificationsServer2, + token: servers[1].accessToken + } + }) + + it('Should send a notification after studio edition', async function () { + this.timeout(240000) + + const { name, shortUUID, id } = await uploadRandomVideoOnServers(servers, 2, { waitTranscoding: true }) + + await waitJobs(servers) + await checkVideoIsPublished({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + + const tasks: VideoStudioTask[] = [ + { + name: 'cut', + options: { + start: 0, + end: 1 + } + } + ] + await servers[1].videoStudio.createEditionTasks({ videoId: id, tasks }) + await waitJobs(servers) + + await checkVideoStudioEditionIsFinished({ ...baseParams, videoName: name, shortUUID, checkType: 'presence' }) + }) + }) + + describe('My video is imported', function () { + let baseParams: CheckerBaseParams + + before(() => { + baseParams = { + server: servers[0], + emails, + socketNotifications: adminNotifications, + token: servers[0].accessToken + } + }) + + it('Should send a notification when the video import failed', async function () { + this.timeout(70000) + + const name = 'video import ' + buildUUID() + + const attributes = { + name, + channelId, + privacy: VideoPrivacy.PRIVATE, + targetUrl: FIXTURE_URLS.badVideo + } + const { video: { shortUUID } } = await servers[0].imports.importVideo({ attributes }) + + await waitJobs(servers) + + const url = FIXTURE_URLS.badVideo + await checkMyVideoImportIsFinished({ ...baseParams, videoName: name, shortUUID, url, success: false, checkType: 'presence' }) + }) + + it('Should send a notification when the video import succeeded', async function () { + this.timeout(70000) + + const name = 'video import ' + buildUUID() + + const attributes = { + name, + channelId, + privacy: VideoPrivacy.PRIVATE, + targetUrl: FIXTURE_URLS.goodVideo + } + const { video: { shortUUID } } = await servers[0].imports.importVideo({ attributes }) + + await waitJobs(servers) + + const url = FIXTURE_URLS.goodVideo + await checkMyVideoImportIsFinished({ ...baseParams, videoName: name, shortUUID, url, success: true, checkType: 'presence' }) + }) + }) + + describe('New actor follow', function () { + let baseParams: CheckerBaseParams + const myChannelName = 'super channel name' + const myUserName = 'super user name' + + before(async function () { + baseParams = { + server: servers[0], + emails, + socketNotifications: userNotifications, + token: userAccessToken + } + + await servers[0].users.updateMe({ displayName: 'super root name' }) + + await servers[0].users.updateMe({ + token: userAccessToken, + displayName: myUserName + }) + + await servers[1].users.updateMe({ displayName: 'super root 2 name' }) + + await servers[0].channels.update({ + token: userAccessToken, + channelName: 'user_1_channel', + attributes: { displayName: myChannelName } + }) + }) + + it('Should notify when a local channel is following one of our channel', async function () { + this.timeout(50000) + + await servers[0].subscriptions.add({ targetUri: 'user_1_channel@' + servers[0].host }) + await waitJobs(servers) + + await checkNewActorFollow({ + ...baseParams, + followType: 'channel', + followerName: 'root', + followerDisplayName: 'super root name', + followingDisplayName: myChannelName, + checkType: 'presence' + }) + + await servers[0].subscriptions.remove({ uri: 'user_1_channel@' + servers[0].host }) + }) + + it('Should notify when a remote channel is following one of our channel', async function () { + this.timeout(50000) + + await servers[1].subscriptions.add({ targetUri: 'user_1_channel@' + servers[0].host }) + await waitJobs(servers) + + await checkNewActorFollow({ + ...baseParams, + followType: 'channel', + followerName: 'root', + followerDisplayName: 'super root 2 name', + followingDisplayName: myChannelName, + checkType: 'presence' + }) + + await servers[1].subscriptions.remove({ uri: 'user_1_channel@' + servers[0].host }) + }) + + // PeerTube does not support account -> account follows + // it('Should notify when a local account is following one of our channel', async function () { + // this.timeout(50000) + // + // await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1@' + servers[0].host) + // + // await waitJobs(servers) + // + // await checkNewActorFollow(baseParams, 'account', 'root', 'super root name', myUserName, 'presence') + // }) + + // it('Should notify when a remote account is following one of our channel', async function () { + // this.timeout(50000) + // + // await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1@' + servers[0].host) + // + // await waitJobs(servers) + // + // await checkNewActorFollow(baseParams, 'account', 'root', 'super root 2 name', myUserName, 'presence') + // }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/object-storage/index.ts b/packages/tests/src/api/object-storage/index.ts new file mode 100644 index 000000000..51d2a29a0 --- /dev/null +++ b/packages/tests/src/api/object-storage/index.ts @@ -0,0 +1,4 @@ +export * from './live.js' +export * from './video-imports.js' +export * from './video-static-file-privacy.js' +export * from './videos.js' diff --git a/packages/tests/src/api/object-storage/live.ts b/packages/tests/src/api/object-storage/live.ts new file mode 100644 index 000000000..c8c214af5 --- /dev/null +++ b/packages/tests/src/api/object-storage/live.ts @@ -0,0 +1,314 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { HttpStatusCode, LiveVideoCreate, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + findExternalSavedVideo, + makeRawRequest, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs, + waitUntilLivePublishedOnAllServers, + waitUntilLiveReplacedByReplayOnAllServers, + waitUntilLiveWaitingOnAllServers +} from '@peertube/peertube-server-commands' +import { expectStartWith } from '@tests/shared/checks.js' +import { testLiveVideoResolutions } from '@tests/shared/live.js' +import { MockObjectStorageProxy } from '@tests/shared/mock-servers/mock-object-storage.js' +import { SQLCommand } from '@tests/shared/sql-command.js' + +async function createLive (server: PeerTubeServer, permanent: boolean) { + const attributes: LiveVideoCreate = { + channelId: server.store.channel.id, + privacy: VideoPrivacy.PUBLIC, + name: 'my super live', + saveReplay: true, + replaySettings: { privacy: VideoPrivacy.PUBLIC }, + permanentLive: permanent + } + + const { uuid } = await server.live.create({ fields: attributes }) + + return uuid +} + +async function checkFilesExist (options: { + servers: PeerTubeServer[] + videoUUID: string + numberOfFiles: number + objectStorage: ObjectStorageCommand +}) { + const { servers, videoUUID, numberOfFiles, objectStorage } = options + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.files).to.have.lengthOf(0) + expect(video.streamingPlaylists).to.have.lengthOf(1) + + const files = video.streamingPlaylists[0].files + expect(files).to.have.lengthOf(numberOfFiles) + + for (const file of files) { + expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + } +} + +async function checkFilesCleanup (options: { + server: PeerTubeServer + videoUUID: string + resolutions: number[] + objectStorage: ObjectStorageCommand +}) { + const { server, videoUUID, resolutions, objectStorage } = options + + const resolutionFiles = resolutions.map((_value, i) => `${i}.m3u8`) + + for (const playlistName of [ 'master.m3u8' ].concat(resolutionFiles)) { + await server.live.getPlaylistFile({ + videoUUID, + playlistName, + expectedStatus: HttpStatusCode.NOT_FOUND_404, + objectStorage + }) + } + + await server.live.getSegmentFile({ + videoUUID, + playlistNumber: 0, + segment: 0, + objectStorage, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) +} + +describe('Object storage for lives', function () { + if (areMockObjectStorageTestsDisabled()) return + + let servers: PeerTubeServer[] + let sqlCommandServer1: SQLCommand + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(120000) + + await objectStorage.prepareDefaultMockBuckets() + servers = await createMultipleServers(2, objectStorage.getDefaultMockConfig()) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableTranscoding() + + sqlCommandServer1 = new SQLCommand(servers[0]) + }) + + describe('Without live transcoding', function () { + let videoUUID: string + + before(async function () { + await servers[0].config.enableLive({ transcoding: false }) + + videoUUID = await createLive(servers[0], false) + }) + + it('Should create a live and publish it on object storage', async function () { + this.timeout(220000) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: videoUUID }) + await waitUntilLivePublishedOnAllServers(servers, videoUUID) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId: videoUUID, + resolutions: [ 720 ], + transcoded: false, + objectStorage + }) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should have saved the replay on object storage', async function () { + this.timeout(220000) + + await waitUntilLiveReplacedByReplayOnAllServers(servers, videoUUID) + await waitJobs(servers) + + await checkFilesExist({ servers, videoUUID, numberOfFiles: 1, objectStorage }) + }) + + it('Should have cleaned up live files from object storage', async function () { + await checkFilesCleanup({ server: servers[0], videoUUID, resolutions: [ 720 ], objectStorage }) + }) + }) + + describe('With live transcoding', function () { + const resolutions = [ 720, 480, 360, 240, 144 ] + + before(async function () { + await servers[0].config.enableLive({ transcoding: true }) + }) + + describe('Normal replay', function () { + let videoUUIDNonPermanent: string + + before(async function () { + videoUUIDNonPermanent = await createLive(servers[0], false) + }) + + it('Should create a live and publish it on object storage', async function () { + this.timeout(240000) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: videoUUIDNonPermanent }) + await waitUntilLivePublishedOnAllServers(servers, videoUUIDNonPermanent) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId: videoUUIDNonPermanent, + resolutions, + transcoded: true, + objectStorage + }) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should have saved the replay on object storage', async function () { + this.timeout(220000) + + await waitUntilLiveReplacedByReplayOnAllServers(servers, videoUUIDNonPermanent) + await waitJobs(servers) + + await checkFilesExist({ servers, videoUUID: videoUUIDNonPermanent, numberOfFiles: 5, objectStorage }) + }) + + it('Should have cleaned up live files from object storage', async function () { + await checkFilesCleanup({ server: servers[0], videoUUID: videoUUIDNonPermanent, resolutions, objectStorage }) + }) + }) + + describe('Permanent replay', function () { + let videoUUIDPermanent: string + + before(async function () { + videoUUIDPermanent = await createLive(servers[0], true) + }) + + it('Should create a live and publish it on object storage', async function () { + this.timeout(240000) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: videoUUIDPermanent }) + await waitUntilLivePublishedOnAllServers(servers, videoUUIDPermanent) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId: videoUUIDPermanent, + resolutions, + transcoded: true, + objectStorage + }) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should have saved the replay on object storage', async function () { + this.timeout(220000) + + await waitUntilLiveWaitingOnAllServers(servers, videoUUIDPermanent) + await waitJobs(servers) + + const videoLiveDetails = await servers[0].videos.get({ id: videoUUIDPermanent }) + const replay = await findExternalSavedVideo(servers[0], videoLiveDetails) + + await checkFilesExist({ servers, videoUUID: replay.uuid, numberOfFiles: 5, objectStorage }) + }) + + it('Should have cleaned up live files from object storage', async function () { + await checkFilesCleanup({ server: servers[0], videoUUID: videoUUIDPermanent, resolutions, objectStorage }) + }) + }) + }) + + describe('With object storage base url', function () { + const mockObjectStorageProxy = new MockObjectStorageProxy() + let baseMockUrl: string + + before(async function () { + this.timeout(120000) + + const port = await mockObjectStorageProxy.initialize() + const bucketName = objectStorage.getMockStreamingPlaylistsBucketName() + baseMockUrl = `http://127.0.0.1:${port}/${bucketName}` + + await objectStorage.prepareDefaultMockBuckets() + + const config = { + object_storage: { + enabled: true, + endpoint: 'http://' + ObjectStorageCommand.getMockEndpointHost(), + region: ObjectStorageCommand.getMockRegion(), + + credentials: ObjectStorageCommand.getMockCredentialsConfig(), + + streaming_playlists: { + bucket_name: bucketName, + prefix: '', + base_url: baseMockUrl + } + } + } + + await servers[0].kill() + await servers[0].run(config) + + await servers[0].config.enableLive({ transcoding: true, resolutions: 'min' }) + }) + + it('Should publish a live and replace the base url', async function () { + this.timeout(240000) + + const videoUUIDPermanent = await createLive(servers[0], true) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: videoUUIDPermanent }) + await waitUntilLivePublishedOnAllServers(servers, videoUUIDPermanent) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId: videoUUIDPermanent, + resolutions: [ 720 ], + transcoded: true, + objectStorage, + objectStorageBaseUrl: baseMockUrl + }) + + await stopFfmpeg(ffmpegCommand) + }) + }) + + after(async function () { + await sqlCommandServer1.cleanup() + await objectStorage.cleanupMock() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/object-storage/video-imports.ts b/packages/tests/src/api/object-storage/video-imports.ts new file mode 100644 index 000000000..43f769842 --- /dev/null +++ b/packages/tests/src/api/object-storage/video-imports.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { expectStartWith } from '@tests/shared/checks.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeRawRequest, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +async function importVideo (server: PeerTubeServer) { + const attributes = { + name: 'import 2', + privacy: VideoPrivacy.PUBLIC, + channelId: server.store.channel.id, + targetUrl: FIXTURE_URLS.goodVideo720 + } + + const { video: { uuid } } = await server.imports.importVideo({ attributes }) + + return uuid +} + +describe('Object storage for video import', function () { + if (areMockObjectStorageTestsDisabled()) return + + let server: PeerTubeServer + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(120000) + + await objectStorage.prepareDefaultMockBuckets() + + server = await createSingleServer(1, objectStorage.getDefaultMockConfig()) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableImports() + }) + + describe('Without transcoding', async function () { + + before(async function () { + await server.config.disableTranscoding() + }) + + it('Should import a video and have sent it to object storage', async function () { + this.timeout(120000) + + const uuid = await importVideo(server) + await waitJobs(server) + + const video = await server.videos.get({ id: uuid }) + + expect(video.files).to.have.lengthOf(1) + expect(video.streamingPlaylists).to.have.lengthOf(0) + + const fileUrl = video.files[0].fileUrl + expectStartWith(fileUrl, objectStorage.getMockWebVideosBaseUrl()) + + await makeRawRequest({ url: fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('With transcoding', async function () { + + before(async function () { + await server.config.enableTranscoding() + }) + + it('Should import a video and have sent it to object storage', async function () { + this.timeout(120000) + + const uuid = await importVideo(server) + await waitJobs(server) + + const video = await server.videos.get({ id: uuid }) + + expect(video.files).to.have.lengthOf(5) + expect(video.streamingPlaylists).to.have.lengthOf(1) + expect(video.streamingPlaylists[0].files).to.have.lengthOf(5) + + for (const file of video.files) { + expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl()) + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + + for (const file of video.streamingPlaylists[0].files) { + expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + }) + + after(async function () { + await objectStorage.cleanupMock() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/object-storage/video-static-file-privacy.ts b/packages/tests/src/api/object-storage/video-static-file-privacy.ts new file mode 100644 index 000000000..cf6e9b4b9 --- /dev/null +++ b/packages/tests/src/api/object-storage/video-static-file-privacy.ts @@ -0,0 +1,573 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { basename } from 'path' +import { getAllFiles, getHLS } from '@peertube/peertube-core-utils' +import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { areScalewayObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createSingleServer, + findExternalSavedVideo, + makeRawRequest, + ObjectStorageCommand, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' +import { expectStartWith } from '@tests/shared/checks.js' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { checkVideoFileTokenReinjection } from '@tests/shared/streaming-playlists.js' + +function extractFilenameFromUrl (url: string) { + const parts = basename(url).split(':') + + return parts[parts.length - 1] +} + +describe('Object storage for video static file privacy', function () { + // We need real world object storage to check ACL + if (areScalewayObjectStorageTestsDisabled()) return + + let server: PeerTubeServer + let sqlCommand: SQLCommand + let userToken: string + + // --------------------------------------------------------------------------- + + async function checkPrivateVODFiles (uuid: string) { + const video = await server.videos.getWithToken({ id: uuid }) + + for (const file of video.files) { + expectStartWith(file.fileUrl, server.url + '/object-storage-proxy/web-videos/private/') + + await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + + for (const file of getAllFiles(video)) { + const internalFileUrl = await sqlCommand.getInternalFileUrl(file.id) + expectStartWith(internalFileUrl, ObjectStorageCommand.getScalewayBaseUrl()) + await makeRawRequest({ url: internalFileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + + const hls = getHLS(video) + + if (hls) { + for (const url of [ hls.playlistUrl, hls.segmentsSha256Url ]) { + expectStartWith(url, server.url + '/object-storage-proxy/streaming-playlists/hls/private/') + } + + await makeRawRequest({ url: hls.playlistUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + + for (const file of hls.files) { + expectStartWith(file.fileUrl, server.url + '/object-storage-proxy/streaming-playlists/hls/private/') + + await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + } + } + + async function checkPublicVODFiles (uuid: string) { + const video = await server.videos.getWithToken({ id: uuid }) + + for (const file of getAllFiles(video)) { + expectStartWith(file.fileUrl, ObjectStorageCommand.getScalewayBaseUrl()) + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = getHLS(video) + + if (hls) { + expectStartWith(hls.playlistUrl, ObjectStorageCommand.getScalewayBaseUrl()) + expectStartWith(hls.segmentsSha256Url, ObjectStorageCommand.getScalewayBaseUrl()) + + await makeRawRequest({ url: hls.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + // --------------------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1, ObjectStorageCommand.getDefaultScalewayConfig({ serverNumber: 1 })) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableMinimumTranscoding() + + userToken = await server.users.generateUserAndToken('user1') + + sqlCommand = new SQLCommand(server) + }) + + describe('VOD', function () { + let privateVideoUUID: string + let publicVideoUUID: string + let passwordProtectedVideoUUID: string + let userPrivateVideoUUID: string + + const correctPassword = 'my super password' + const correctPasswordHeader = { 'x-peertube-video-password': correctPassword } + const incorrectPasswordHeader = { 'x-peertube-video-password': correctPassword + 'toto' } + + // --------------------------------------------------------------------------- + + async function getSampleFileUrls (videoId: string) { + const video = await server.videos.getWithToken({ id: videoId }) + + return { + webVideoFile: video.files[0].fileUrl, + hlsFile: getHLS(video).files[0].fileUrl + } + } + + // --------------------------------------------------------------------------- + + it('Should upload a private video and have appropriate object storage ACL', async function () { + this.timeout(120000) + + { + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + privateVideoUUID = uuid + } + + { + const { uuid } = await server.videos.quickUpload({ name: 'user video', token: userToken, privacy: VideoPrivacy.PRIVATE }) + userPrivateVideoUUID = uuid + } + + await waitJobs([ server ]) + + await checkPrivateVODFiles(privateVideoUUID) + }) + + it('Should upload a password protected video and have appropriate object storage ACL', async function () { + this.timeout(120000) + + { + const { uuid } = await server.videos.quickUpload({ + name: 'video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ correctPassword ] + }) + passwordProtectedVideoUUID = uuid + } + await waitJobs([ server ]) + + await checkPrivateVODFiles(passwordProtectedVideoUUID) + }) + + it('Should upload a public video and have appropriate object storage ACL', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED }) + await waitJobs([ server ]) + + publicVideoUUID = uuid + + await checkPublicVODFiles(publicVideoUUID) + }) + + it('Should not get files without appropriate OAuth token', async function () { + this.timeout(60000) + + const { webVideoFile, hlsFile } = await getSampleFileUrls(privateVideoUUID) + + await makeRawRequest({ url: webVideoFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url: webVideoFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should not get files without appropriate password or appropriate OAuth token', async function () { + this.timeout(60000) + + const { webVideoFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID) + + await makeRawRequest({ url: webVideoFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ + url: webVideoFile, + token: null, + headers: incorrectPasswordHeader, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + await makeRawRequest({ url: webVideoFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ + url: webVideoFile, + token: null, + headers: correctPasswordHeader, + expectedStatus: HttpStatusCode.OK_200 + }) + + await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ + url: hlsFile, + token: null, + headers: incorrectPasswordHeader, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ + url: hlsFile, + token: null, + headers: correctPasswordHeader, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should not get HLS file of another video', async function () { + this.timeout(60000) + + const privateVideo = await server.videos.getWithToken({ id: privateVideoUUID }) + const hlsFilename = basename(getHLS(privateVideo).files[0].fileUrl) + + const badUrl = server.url + '/object-storage-proxy/streaming-playlists/hls/private/' + userPrivateVideoUUID + '/' + hlsFilename + const goodUrl = server.url + '/object-storage-proxy/streaming-playlists/hls/private/' + privateVideoUUID + '/' + hlsFilename + + await makeRawRequest({ url: badUrl, token: server.accessToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should correctly check OAuth, video file token of private video', async function () { + this.timeout(60000) + + const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) + const goodVideoFileToken = await server.videoToken.getVideoFileToken({ videoId: privateVideoUUID }) + + const { webVideoFile, hlsFile } = await getSampleFileUrls(privateVideoUUID) + + for (const url of [ webVideoFile, hlsFile ]) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + } + }) + + it('Should correctly check OAuth, video file token or video password of password protected video', async function () { + this.timeout(60000) + + const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) + const goodVideoFileToken = await server.videoToken.getVideoFileToken({ + videoId: passwordProtectedVideoUUID, + videoPassword: correctPassword + }) + + const { webVideoFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID) + + for (const url of [ hlsFile, webVideoFile ]) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ + url, + headers: incorrectPasswordHeader, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + await makeRawRequest({ url, headers: correctPasswordHeader, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should reinject video file token', async function () { + this.timeout(120000) + + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: privateVideoUUID }) + + await checkVideoFileTokenReinjection({ + server, + videoUUID: privateVideoUUID, + videoFileToken, + resolutions: [ 240, 720 ], + isLive: false + }) + }) + + it('Should update public video to private', async function () { + this.timeout(60000) + + await server.videos.update({ id: publicVideoUUID, attributes: { privacy: VideoPrivacy.INTERNAL } }) + + await checkPrivateVODFiles(publicVideoUUID) + }) + + it('Should update private video to public', async function () { + this.timeout(60000) + + await server.videos.update({ id: publicVideoUUID, attributes: { privacy: VideoPrivacy.PUBLIC } }) + + await checkPublicVODFiles(publicVideoUUID) + }) + }) + + describe('Live', function () { + let normalLiveId: string + let normalLive: LiveVideo + + let permanentLiveId: string + let permanentLive: LiveVideo + + let passwordProtectedLiveId: string + let passwordProtectedLive: LiveVideo + + const correctPassword = 'my super password' + + let unrelatedFileToken: string + + // --------------------------------------------------------------------------- + + async function checkLiveFiles (live: LiveVideo, liveId: string, videoPassword?: string) { + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await server.live.waitUntilPublished({ videoId: liveId }) + + const video = videoPassword + ? await server.videos.getWithPassword({ id: liveId, password: videoPassword }) + : await server.videos.getWithToken({ id: liveId }) + + const fileToken = videoPassword + ? await server.videoToken.getVideoFileToken({ token: null, videoId: video.uuid, videoPassword }) + : await server.videoToken.getVideoFileToken({ videoId: video.uuid }) + + const hls = video.streamingPlaylists[0] + + for (const url of [ hls.playlistUrl, hls.segmentsSha256Url ]) { + expectStartWith(url, server.url + '/object-storage-proxy/streaming-playlists/hls/private/') + + await makeRawRequest({ url: hls.playlistUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + if (videoPassword) { + await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 }) + } + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + if (videoPassword) { + await makeRawRequest({ + url, + headers: { 'x-peertube-video-password': 'incorrectPassword' }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + } + + await stopFfmpeg(ffmpegCommand) + } + + async function checkReplay (replay: VideoDetails) { + const fileToken = await server.videoToken.getVideoFileToken({ videoId: replay.uuid }) + + const hls = replay.streamingPlaylists[0] + expect(hls.files).to.not.have.lengthOf(0) + + for (const file of hls.files) { + await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.fileUrl, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url: file.fileUrl, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ + url: file.fileUrl, + query: { videoFileToken: unrelatedFileToken }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + + for (const url of [ hls.playlistUrl, hls.segmentsSha256Url ]) { + expectStartWith(url, server.url + '/object-storage-proxy/streaming-playlists/hls/private/') + + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + } + + // --------------------------------------------------------------------------- + + before(async function () { + await server.config.enableMinimumTranscoding() + + const { uuid } = await server.videos.quickUpload({ name: 'another video' }) + unrelatedFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + + await server.config.enableLive({ + allowReplay: true, + transcoding: true, + resolutions: 'min' + }) + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: true, + permanentLive: false, + privacy: VideoPrivacy.PRIVATE + }) + normalLiveId = video.uuid + normalLive = live + } + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: true, + permanentLive: true, + privacy: VideoPrivacy.PRIVATE + }) + permanentLiveId = video.uuid + permanentLive = live + } + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: false, + permanentLive: false, + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ correctPassword ] + }) + passwordProtectedLiveId = video.uuid + passwordProtectedLive = live + } + }) + + it('Should create a private normal live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles(normalLive, normalLiveId) + }) + + it('Should create a private permanent live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles(permanentLive, permanentLiveId) + }) + + it('Should create a password protected live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles(passwordProtectedLive, passwordProtectedLiveId, correctPassword) + }) + + it('Should reinject video file token in permanent live', async function () { + this.timeout(240000) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: permanentLive.rtmpUrl, streamKey: permanentLive.streamKey }) + await server.live.waitUntilPublished({ videoId: permanentLiveId }) + + const video = await server.videos.getWithToken({ id: permanentLiveId }) + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) + + await checkVideoFileTokenReinjection({ + server, + videoUUID: permanentLiveId, + videoFileToken, + resolutions: [ 720 ], + isLive: true + }) + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should have created a replay of the normal live with a private static path', async function () { + this.timeout(240000) + + await server.live.waitUntilReplacedByReplay({ videoId: normalLiveId }) + + const replay = await server.videos.getWithToken({ id: normalLiveId }) + await checkReplay(replay) + }) + + it('Should have created a replay of the permanent live with a private static path', async function () { + this.timeout(240000) + + await server.live.waitUntilWaiting({ videoId: permanentLiveId }) + await waitJobs([ server ]) + + const live = await server.videos.getWithToken({ id: permanentLiveId }) + const replayFromList = await findExternalSavedVideo(server, live) + const replay = await server.videos.getWithToken({ id: replayFromList.id }) + + await checkReplay(replay) + }) + }) + + describe('With private files proxy disabled and public ACL for private files', function () { + let videoUUID: string + + before(async function () { + this.timeout(240000) + + await server.kill() + + const config = ObjectStorageCommand.getDefaultScalewayConfig({ + serverNumber: 1, + enablePrivateProxy: false, + privateACL: 'public-read' + }) + await server.run(config) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + videoUUID = uuid + + await waitJobs([ server ]) + }) + + it('Should display object storage path for a private video and be able to access them', async function () { + this.timeout(60000) + + await checkPublicVODFiles(videoUUID) + }) + + it('Should not be able to access object storage proxy', async function () { + const privateVideo = await server.videos.getWithToken({ id: videoUUID }) + const webVideoFilename = extractFilenameFromUrl(privateVideo.files[0].fileUrl) + const hlsFilename = extractFilenameFromUrl(getHLS(privateVideo).files[0].fileUrl) + + await makeRawRequest({ + url: server.url + '/object-storage-proxy/web-videos/private/' + webVideoFilename, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeRawRequest({ + url: server.url + '/object-storage-proxy/streaming-playlists/hls/private/' + videoUUID + '/' + hlsFilename, + token: server.accessToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + }) + + after(async function () { + this.timeout(240000) + + const { data } = await server.videos.listAllForAdmin() + + for (const v of data) { + await server.videos.remove({ id: v.uuid }) + } + + for (const v of data) { + await server.servers.waitUntilLog('Removed files of video ' + v.url) + } + + await sqlCommand.cleanup() + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/object-storage/videos.ts b/packages/tests/src/api/object-storage/videos.ts new file mode 100644 index 000000000..66bca5cc8 --- /dev/null +++ b/packages/tests/src/api/object-storage/videos.ts @@ -0,0 +1,434 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import bytes from 'bytes' +import { expect } from 'chai' +import { stat } from 'fs/promises' +import merge from 'lodash-es/merge.js' +import { HttpStatusCode, VideoDetails } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled, sha1 } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + createSingleServer, + doubleFollow, + killallServers, + makeRawRequest, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { expectStartWith, expectLogDoesNotContain } from '@tests/shared/checks.js' +import { checkTmpIsEmpty } from '@tests/shared/directories.js' +import { generateHighBitrateVideo } from '@tests/shared/generate.js' +import { MockObjectStorageProxy } from '@tests/shared/mock-servers/mock-object-storage.js' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { checkWebTorrentWorks } from '@tests/shared/webtorrent.js' + +async function checkFiles (options: { + server: PeerTubeServer + originServer: PeerTubeServer + originSQLCommand: SQLCommand + + video: VideoDetails + + baseMockUrl?: string + + playlistBucket: string + playlistPrefix?: string + + webVideoBucket: string + webVideoPrefix?: string +}) { + const { + server, + originServer, + originSQLCommand, + video, + playlistBucket, + webVideoBucket, + baseMockUrl, + playlistPrefix, + webVideoPrefix + } = options + + let allFiles = video.files + + for (const file of video.files) { + const baseUrl = baseMockUrl + ? `${baseMockUrl}/${webVideoBucket}/` + : `http://${webVideoBucket}.${ObjectStorageCommand.getMockEndpointHost()}/` + + const prefix = webVideoPrefix || '' + const start = baseUrl + prefix + + expectStartWith(file.fileUrl, start) + + const res = await makeRawRequest({ url: file.fileDownloadUrl, expectedStatus: HttpStatusCode.FOUND_302 }) + const location = res.headers['location'] + expectStartWith(location, start) + + await makeRawRequest({ url: location, expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = video.streamingPlaylists[0] + + if (hls) { + allFiles = allFiles.concat(hls.files) + + const baseUrl = baseMockUrl + ? `${baseMockUrl}/${playlistBucket}/` + : `http://${playlistBucket}.${ObjectStorageCommand.getMockEndpointHost()}/` + + const prefix = playlistPrefix || '' + const start = baseUrl + prefix + + expectStartWith(hls.playlistUrl, start) + expectStartWith(hls.segmentsSha256Url, start) + + await makeRawRequest({ url: hls.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + + const resSha = await makeRawRequest({ url: hls.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) + expect(JSON.stringify(resSha.body)).to.not.throw + + let i = 0 + for (const file of hls.files) { + expectStartWith(file.fileUrl, start) + + const res = await makeRawRequest({ url: file.fileDownloadUrl, expectedStatus: HttpStatusCode.FOUND_302 }) + const location = res.headers['location'] + expectStartWith(location, start) + + await makeRawRequest({ url: location, expectedStatus: HttpStatusCode.OK_200 }) + + if (originServer.internalServerNumber === server.internalServerNumber) { + const infohash = sha1(`${2 + hls.playlistUrl}+V${i}`) + const dbInfohashes = await originSQLCommand.getPlaylistInfohash(hls.id) + + expect(dbInfohashes).to.include(infohash) + } + + i++ + } + } + + for (const file of allFiles) { + await checkWebTorrentWorks(file.magnetUri) + + const res = await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.body).to.have.length.above(100) + } + + return allFiles.map(f => f.fileUrl) +} + +function runTestSuite (options: { + fixture?: string + + maxUploadPart?: string + + playlistBucket: string + playlistPrefix?: string + + webVideoBucket: string + webVideoPrefix?: string + + useMockBaseUrl?: boolean +}) { + const mockObjectStorageProxy = new MockObjectStorageProxy() + const { fixture } = options + let baseMockUrl: string + + let servers: PeerTubeServer[] + let sqlCommands: SQLCommand[] = [] + const objectStorage = new ObjectStorageCommand() + + let keptUrls: string[] = [] + + const uuidsToDelete: string[] = [] + let deletedUrls: string[] = [] + + before(async function () { + this.timeout(240000) + + const port = await mockObjectStorageProxy.initialize() + baseMockUrl = options.useMockBaseUrl + ? `http://127.0.0.1:${port}` + : undefined + + await objectStorage.createMockBucket(options.playlistBucket) + await objectStorage.createMockBucket(options.webVideoBucket) + + const config = { + object_storage: { + enabled: true, + endpoint: 'http://' + ObjectStorageCommand.getMockEndpointHost(), + region: ObjectStorageCommand.getMockRegion(), + + credentials: ObjectStorageCommand.getMockCredentialsConfig(), + + max_upload_part: options.maxUploadPart || '5MB', + + streaming_playlists: { + bucket_name: options.playlistBucket, + prefix: options.playlistPrefix, + base_url: baseMockUrl + ? `${baseMockUrl}/${options.playlistBucket}` + : undefined + }, + + web_videos: { + bucket_name: options.webVideoBucket, + prefix: options.webVideoPrefix, + base_url: baseMockUrl + ? `${baseMockUrl}/${options.webVideoBucket}` + : undefined + } + } + } + + servers = await createMultipleServers(2, config) + + await setAccessTokensToServers(servers) + await doubleFollow(servers[0], servers[1]) + + for (const server of servers) { + const { uuid } = await server.videos.quickUpload({ name: 'video to keep' }) + await waitJobs(servers) + + const files = await server.videos.listFiles({ id: uuid }) + keptUrls = keptUrls.concat(files.map(f => f.fileUrl)) + } + + sqlCommands = servers.map(s => new SQLCommand(s)) + }) + + it('Should upload a video and move it to the object storage without transcoding', async function () { + this.timeout(40000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 1', fixture }) + uuidsToDelete.push(uuid) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + const files = await checkFiles({ ...options, server, originServer: servers[0], originSQLCommand: sqlCommands[0], video, baseMockUrl }) + + deletedUrls = deletedUrls.concat(files) + } + }) + + it('Should upload a video and move it to the object storage with transcoding', async function () { + this.timeout(120000) + + const { uuid } = await servers[1].videos.quickUpload({ name: 'video 2', fixture }) + uuidsToDelete.push(uuid) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + const files = await checkFiles({ ...options, server, originServer: servers[0], originSQLCommand: sqlCommands[0], video, baseMockUrl }) + + deletedUrls = deletedUrls.concat(files) + } + }) + + it('Should fetch correctly all the files', async function () { + for (const url of deletedUrls.concat(keptUrls)) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should correctly delete the files', async function () { + await servers[0].videos.remove({ id: uuidsToDelete[0] }) + await servers[1].videos.remove({ id: uuidsToDelete[1] }) + + await waitJobs(servers) + + for (const url of deletedUrls) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + }) + + it('Should have kept other files', async function () { + for (const url of keptUrls) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should have an empty tmp directory', async function () { + for (const server of servers) { + await checkTmpIsEmpty(server) + } + }) + + it('Should not have downloaded files from object storage', async function () { + for (const server of servers) { + await expectLogDoesNotContain(server, 'from object storage') + } + }) + + after(async function () { + await mockObjectStorageProxy.terminate() + await objectStorage.cleanupMock() + + for (const sqlCommand of sqlCommands) { + await sqlCommand.cleanup() + } + + await cleanupTests(servers) + }) +} + +describe('Object storage for videos', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + describe('Test config', function () { + let server: PeerTubeServer + + const baseConfig = objectStorage.getDefaultMockConfig() + + const badCredentials = { + access_key_id: 'AKIAIOSFODNN7EXAMPLE', + secret_access_key: 'aJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + } + + it('Should fail with same bucket names without prefix', function (done) { + const config = merge({}, baseConfig, { + object_storage: { + streaming_playlists: { + bucket_name: 'aaa' + }, + + web_videos: { + bucket_name: 'aaa' + } + } + }) + + createSingleServer(1, config) + .then(() => done(new Error('Did not throw'))) + .catch(() => done()) + }) + + it('Should fail with bad credentials', async function () { + this.timeout(60000) + + await objectStorage.prepareDefaultMockBuckets() + + const config = merge({}, baseConfig, { + object_storage: { + credentials: badCredentials + } + }) + + server = await createSingleServer(1, config) + await setAccessTokensToServers([ server ]) + + const { uuid } = await server.videos.quickUpload({ name: 'video' }) + + await waitJobs([ server ], { skipDelayed: true }) + const video = await server.videos.get({ id: uuid }) + + expectStartWith(video.files[0].fileUrl, server.url) + + await killallServers([ server ]) + }) + + it('Should succeed with credentials from env', async function () { + this.timeout(60000) + + await objectStorage.prepareDefaultMockBuckets() + + const config = merge({}, baseConfig, { + object_storage: { + credentials: { + access_key_id: '', + secret_access_key: '' + } + } + }) + + const goodCredentials = ObjectStorageCommand.getMockCredentialsConfig() + + server = await createSingleServer(1, config, { + env: { + AWS_ACCESS_KEY_ID: goodCredentials.access_key_id, + AWS_SECRET_ACCESS_KEY: goodCredentials.secret_access_key + } + }) + + await setAccessTokensToServers([ server ]) + + const { uuid } = await server.videos.quickUpload({ name: 'video' }) + + await waitJobs([ server ], { skipDelayed: true }) + const video = await server.videos.get({ id: uuid }) + + expectStartWith(video.files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl()) + }) + + after(async function () { + await objectStorage.cleanupMock() + + await cleanupTests([ server ]) + }) + }) + + describe('Test simple object storage', function () { + runTestSuite({ + playlistBucket: objectStorage.getMockBucketName('streaming-playlists'), + webVideoBucket: objectStorage.getMockBucketName('web-videos') + }) + }) + + describe('Test object storage with prefix', function () { + runTestSuite({ + playlistBucket: objectStorage.getMockBucketName('mybucket'), + webVideoBucket: objectStorage.getMockBucketName('mybucket'), + + playlistPrefix: 'streaming-playlists_', + webVideoPrefix: 'webvideo_' + }) + }) + + describe('Test object storage with prefix and base URL', function () { + runTestSuite({ + playlistBucket: objectStorage.getMockBucketName('mybucket'), + webVideoBucket: objectStorage.getMockBucketName('mybucket'), + + playlistPrefix: 'streaming-playlists/', + webVideoPrefix: 'webvideo/', + + useMockBaseUrl: true + }) + }) + + describe('Test object storage with file bigger than upload part', function () { + let fixture: string + const maxUploadPart = '5MB' + + before(async function () { + this.timeout(120000) + + fixture = await generateHighBitrateVideo() + + const { size } = await stat(fixture) + + if (bytes.parse(maxUploadPart) > size) { + throw Error(`Fixture file is too small (${size}) to make sense for this test.`) + } + }) + + runTestSuite({ + maxUploadPart, + playlistBucket: objectStorage.getMockBucketName('streaming-playlists'), + webVideoBucket: objectStorage.getMockBucketName('web-videos'), + fixture + }) + }) +}) diff --git a/packages/tests/src/api/redundancy/index.ts b/packages/tests/src/api/redundancy/index.ts new file mode 100644 index 000000000..f6b70c8af --- /dev/null +++ b/packages/tests/src/api/redundancy/index.ts @@ -0,0 +1,3 @@ +import './redundancy-constraints.js' +import './redundancy.js' +import './manage-redundancy.js' diff --git a/packages/tests/src/api/redundancy/manage-redundancy.ts b/packages/tests/src/api/redundancy/manage-redundancy.ts new file mode 100644 index 000000000..14556e26c --- /dev/null +++ b/packages/tests/src/api/redundancy/manage-redundancy.ts @@ -0,0 +1,324 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + RedundancyCommand, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { VideoPrivacy, VideoRedundanciesTarget } from '@peertube/peertube-models' + +describe('Test manage videos redundancy', function () { + const targets: VideoRedundanciesTarget[] = [ 'my-videos', 'remote-videos' ] + + let servers: PeerTubeServer[] + let video1Server2UUID: string + let video2Server2UUID: string + let redundanciesToRemove: number[] = [] + + let commands: RedundancyCommand[] + + before(async function () { + this.timeout(120000) + + const config = { + transcoding: { + hls: { + enabled: true + } + }, + redundancy: { + videos: { + check_interval: '1 second', + strategies: [ + { + strategy: 'recently-added', + min_lifetime: '1 hour', + size: '10MB', + min_views: 0 + } + ] + } + } + } + servers = await createMultipleServers(3, config) + + // Get the access tokens + await setAccessTokensToServers(servers) + + commands = servers.map(s => s.redundancy) + + { + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'video 1 server 2' } }) + video1Server2UUID = uuid + } + + { + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'video 2 server 2' } }) + video2Server2UUID = uuid + } + + await waitJobs(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + await doubleFollow(servers[0], servers[2]) + await commands[0].updateRedundancy({ host: servers[1].host, redundancyAllowed: true }) + + await waitJobs(servers) + }) + + it('Should not have redundancies on server 3', async function () { + for (const target of targets) { + const body = await commands[2].listVideos({ target }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should correctly list followings by redundancy', async function () { + const body = await servers[0].follows.getFollowings({ sort: '-redundancyAllowed' }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + expect(body.data[0].following.host).to.equal(servers[1].host) + expect(body.data[1].following.host).to.equal(servers[2].host) + }) + + it('Should not have "remote-videos" redundancies on server 2', async function () { + this.timeout(120000) + + await waitJobs(servers) + await servers[0].servers.waitUntilLog('Duplicated ', 10) + await waitJobs(servers) + + const body = await commands[1].listVideos({ target: 'remote-videos' }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should have "my-videos" redundancies on server 2', async function () { + this.timeout(120000) + + const body = await commands[1].listVideos({ target: 'my-videos' }) + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + + const videos1 = videos.find(v => v.uuid === video1Server2UUID) + const videos2 = videos.find(v => v.uuid === video2Server2UUID) + + expect(videos1.name).to.equal('video 1 server 2') + expect(videos2.name).to.equal('video 2 server 2') + + expect(videos1.redundancies.files).to.have.lengthOf(4) + expect(videos1.redundancies.streamingPlaylists).to.have.lengthOf(1) + + const redundancies = videos1.redundancies.files.concat(videos1.redundancies.streamingPlaylists) + + for (const r of redundancies) { + expect(r.strategy).to.be.null + expect(r.fileUrl).to.exist + expect(r.createdAt).to.exist + expect(r.updatedAt).to.exist + expect(r.expiresOn).to.exist + } + }) + + it('Should not have "my-videos" redundancies on server 1', async function () { + const body = await commands[0].listVideos({ target: 'my-videos' }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should have "remote-videos" redundancies on server 1', async function () { + this.timeout(120000) + + const body = await commands[0].listVideos({ target: 'remote-videos' }) + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + + const videos1 = videos.find(v => v.uuid === video1Server2UUID) + const videos2 = videos.find(v => v.uuid === video2Server2UUID) + + expect(videos1.name).to.equal('video 1 server 2') + expect(videos2.name).to.equal('video 2 server 2') + + expect(videos1.redundancies.files).to.have.lengthOf(4) + expect(videos1.redundancies.streamingPlaylists).to.have.lengthOf(1) + + const redundancies = videos1.redundancies.files.concat(videos1.redundancies.streamingPlaylists) + + for (const r of redundancies) { + expect(r.strategy).to.equal('recently-added') + expect(r.fileUrl).to.exist + expect(r.createdAt).to.exist + expect(r.updatedAt).to.exist + expect(r.expiresOn).to.exist + } + }) + + it('Should correctly paginate and sort results', async function () { + { + const body = await commands[0].listVideos({ + target: 'remote-videos', + sort: 'name', + start: 0, + count: 2 + }) + + const videos = body.data + expect(videos[0].name).to.equal('video 1 server 2') + expect(videos[1].name).to.equal('video 2 server 2') + } + + { + const body = await commands[0].listVideos({ + target: 'remote-videos', + sort: '-name', + start: 0, + count: 2 + }) + + const videos = body.data + expect(videos[0].name).to.equal('video 2 server 2') + expect(videos[1].name).to.equal('video 1 server 2') + } + + { + const body = await commands[0].listVideos({ + target: 'remote-videos', + sort: '-name', + start: 1, + count: 1 + }) + + expect(body.data[0].name).to.equal('video 1 server 2') + } + }) + + it('Should manually add a redundancy and list it', async function () { + this.timeout(120000) + + const uuid = (await servers[1].videos.quickUpload({ name: 'video 3 server 2', privacy: VideoPrivacy.UNLISTED })).uuid + await waitJobs(servers) + const videoId = await servers[0].videos.getId({ uuid }) + + await commands[0].addVideo({ videoId }) + + await waitJobs(servers) + await servers[0].servers.waitUntilLog('Duplicated ', 15) + await waitJobs(servers) + + { + const body = await commands[0].listVideos({ + target: 'remote-videos', + sort: '-name', + start: 0, + count: 5 + }) + + const video = body.data[0] + + expect(video.name).to.equal('video 3 server 2') + expect(video.redundancies.files).to.have.lengthOf(4) + expect(video.redundancies.streamingPlaylists).to.have.lengthOf(1) + + const redundancies = video.redundancies.files.concat(video.redundancies.streamingPlaylists) + + for (const r of redundancies) { + redundanciesToRemove.push(r.id) + + expect(r.strategy).to.equal('manual') + expect(r.fileUrl).to.exist + expect(r.createdAt).to.exist + expect(r.updatedAt).to.exist + expect(r.expiresOn).to.be.null + } + } + + const body = await commands[1].listVideos({ + target: 'my-videos', + sort: '-name', + start: 0, + count: 5 + }) + + const video = body.data[0] + expect(video.name).to.equal('video 3 server 2') + expect(video.redundancies.files).to.have.lengthOf(4) + expect(video.redundancies.streamingPlaylists).to.have.lengthOf(1) + + const redundancies = video.redundancies.files.concat(video.redundancies.streamingPlaylists) + + for (const r of redundancies) { + expect(r.strategy).to.be.null + expect(r.fileUrl).to.exist + expect(r.createdAt).to.exist + expect(r.updatedAt).to.exist + expect(r.expiresOn).to.be.null + } + }) + + it('Should manually remove a redundancy and remove it from the list', async function () { + this.timeout(120000) + + for (const redundancyId of redundanciesToRemove) { + await commands[0].removeVideo({ redundancyId }) + } + + { + const body = await commands[0].listVideos({ + target: 'remote-videos', + sort: '-name', + start: 0, + count: 5 + }) + + const videos = body.data + + expect(videos).to.have.lengthOf(2) + + const video = videos[0] + expect(video.name).to.equal('video 2 server 2') + expect(video.redundancies.files).to.have.lengthOf(4) + expect(video.redundancies.streamingPlaylists).to.have.lengthOf(1) + + const redundancies = video.redundancies.files.concat(video.redundancies.streamingPlaylists) + + redundanciesToRemove = redundancies.map(r => r.id) + } + }) + + it('Should remove another (auto) redundancy', async function () { + for (const redundancyId of redundanciesToRemove) { + await commands[0].removeVideo({ redundancyId }) + } + + const body = await commands[0].listVideos({ + target: 'remote-videos', + sort: '-name', + start: 0, + count: 5 + }) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('video 1 server 2') + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/redundancy/redundancy-constraints.ts b/packages/tests/src/api/redundancy/redundancy-constraints.ts new file mode 100644 index 000000000..24966b270 --- /dev/null +++ b/packages/tests/src/api/redundancy/redundancy-constraints.ts @@ -0,0 +1,191 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + killallServers, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test redundancy constraints', function () { + let remoteServer: PeerTubeServer + let localServer: PeerTubeServer + let servers: PeerTubeServer[] + + const remoteServerConfig = { + redundancy: { + videos: { + check_interval: '1 second', + strategies: [ + { + strategy: 'recently-added', + min_lifetime: '1 hour', + size: '100MB', + min_views: 0 + } + ] + } + } + } + + async function uploadWrapper (videoName: string) { + // Wait for transcoding + const { id } = await localServer.videos.upload({ attributes: { name: 'to transcode', privacy: VideoPrivacy.PRIVATE } }) + await waitJobs([ localServer ]) + + // Update video to schedule a federation + await localServer.videos.update({ id, attributes: { name: videoName, privacy: VideoPrivacy.PUBLIC } }) + } + + async function getTotalRedundanciesLocalServer () { + const body = await localServer.redundancy.listVideos({ target: 'my-videos' }) + + return body.total + } + + async function getTotalRedundanciesRemoteServer () { + const body = await remoteServer.redundancy.listVideos({ target: 'remote-videos' }) + + return body.total + } + + before(async function () { + this.timeout(120000) + + { + remoteServer = await createSingleServer(1, remoteServerConfig) + } + + { + const config = { + remote_redundancy: { + videos: { + accept_from: 'nobody' + } + } + } + localServer = await createSingleServer(2, config) + } + + servers = [ remoteServer, localServer ] + + // Get the access tokens + await setAccessTokensToServers(servers) + + await localServer.videos.upload({ attributes: { name: 'video 1 server 2' } }) + + await waitJobs(servers) + + // Server 1 and server 2 follow each other + await remoteServer.follows.follow({ hosts: [ localServer.url ] }) + await waitJobs(servers) + await remoteServer.redundancy.updateRedundancy({ host: localServer.host, redundancyAllowed: true }) + + await waitJobs(servers) + }) + + it('Should have redundancy on server 1 but not on server 2 with a nobody filter', async function () { + this.timeout(120000) + + await waitJobs(servers) + await remoteServer.servers.waitUntilLog('Duplicated ', 5) + await waitJobs(servers) + + { + const total = await getTotalRedundanciesRemoteServer() + expect(total).to.equal(1) + } + + { + const total = await getTotalRedundanciesLocalServer() + expect(total).to.equal(0) + } + }) + + it('Should have redundancy on server 1 and on server 2 with an anybody filter', async function () { + this.timeout(120000) + + const config = { + remote_redundancy: { + videos: { + accept_from: 'anybody' + } + } + } + await killallServers([ localServer ]) + await localServer.run(config) + + await uploadWrapper('video 2 server 2') + + await remoteServer.servers.waitUntilLog('Duplicated ', 10) + await waitJobs(servers) + + { + const total = await getTotalRedundanciesRemoteServer() + expect(total).to.equal(2) + } + + { + const total = await getTotalRedundanciesLocalServer() + expect(total).to.equal(1) + } + }) + + it('Should have redundancy on server 1 but not on server 2 with a followings filter', async function () { + this.timeout(120000) + + const config = { + remote_redundancy: { + videos: { + accept_from: 'followings' + } + } + } + await killallServers([ localServer ]) + await localServer.run(config) + + await uploadWrapper('video 3 server 2') + + await remoteServer.servers.waitUntilLog('Duplicated ', 15) + await waitJobs(servers) + + { + const total = await getTotalRedundanciesRemoteServer() + expect(total).to.equal(3) + } + + { + const total = await getTotalRedundanciesLocalServer() + expect(total).to.equal(1) + } + }) + + it('Should have redundancy on server 1 and on server 2 with followings filter now server 2 follows server 1', async function () { + this.timeout(120000) + + await localServer.follows.follow({ hosts: [ remoteServer.url ] }) + await waitJobs(servers) + + await uploadWrapper('video 4 server 2') + await remoteServer.servers.waitUntilLog('Duplicated ', 20) + await waitJobs(servers) + + { + const total = await getTotalRedundanciesRemoteServer() + expect(total).to.equal(4) + } + + { + const total = await getTotalRedundanciesLocalServer() + expect(total).to.equal(2) + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/redundancy/redundancy.ts b/packages/tests/src/api/redundancy/redundancy.ts new file mode 100644 index 000000000..69afae037 --- /dev/null +++ b/packages/tests/src/api/redundancy/redundancy.ts @@ -0,0 +1,743 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { readdir } from 'fs/promises' +import { decode as magnetUriDecode } from 'magnet-uri' +import { basename, join } from 'path' +import { wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + VideoDetails, + VideoFile, + VideoPrivacy, + VideoRedundancyStrategy, + VideoRedundancyStrategyWithManual +} from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + killallServers, + makeRawRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { checkSegmentHash } from '@tests/shared/streaming-playlists.js' +import { checkVideoFilesWereRemoved, saveVideoInServers } from '@tests/shared/videos.js' + +let servers: PeerTubeServer[] = [] +let video1Server2: VideoDetails + +async function checkMagnetWebseeds (file: VideoFile, baseWebseeds: string[], server: PeerTubeServer) { + const parsed = magnetUriDecode(file.magnetUri) + + for (const ws of baseWebseeds) { + const found = parsed.urlList.find(url => url === `${ws}${basename(file.fileUrl)}`) + expect(found, `Webseed ${ws} not found in ${file.magnetUri} on server ${server.url}`).to.not.be.undefined + } + + expect(parsed.urlList).to.have.lengthOf(baseWebseeds.length) + + for (const url of parsed.urlList) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) + } +} + +async function createServers (strategy: VideoRedundancyStrategy | null, additionalParams: any = {}, withWebVideo = true) { + const strategies: any[] = [] + + if (strategy !== null) { + strategies.push( + { + min_lifetime: '1 hour', + strategy, + size: '400KB', + + ...additionalParams + } + ) + } + + const config = { + transcoding: { + web_videos: { + enabled: withWebVideo + }, + hls: { + enabled: true + } + }, + redundancy: { + videos: { + check_interval: '5 seconds', + strategies + } + } + } + + servers = await createMultipleServers(3, config) + + // Get the access tokens + await setAccessTokensToServers(servers) + + { + const { id } = await servers[1].videos.upload({ attributes: { name: 'video 1 server 2' } }) + video1Server2 = await servers[1].videos.get({ id }) + + await servers[1].views.simulateView({ id }) + } + + await waitJobs(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + // Server 1 and server 3 follow each other + await doubleFollow(servers[0], servers[2]) + // Server 2 and server 3 follow each other + await doubleFollow(servers[1], servers[2]) + + await waitJobs(servers) +} + +async function ensureSameFilenames (videoUUID: string) { + let webVideoFilenames: string[] + let hlsFilenames: string[] + + for (const server of servers) { + const video = await server.videos.getWithToken({ id: videoUUID }) + + // Ensure we use the same filenames that the origin + + const localWebVideoFilenames = video.files.map(f => basename(f.fileUrl)).sort() + const localHLSFilenames = video.streamingPlaylists[0].files.map(f => basename(f.fileUrl)).sort() + + if (webVideoFilenames) expect(webVideoFilenames).to.deep.equal(localWebVideoFilenames) + else webVideoFilenames = localWebVideoFilenames + + if (hlsFilenames) expect(hlsFilenames).to.deep.equal(localHLSFilenames) + else hlsFilenames = localHLSFilenames + } + + return { webVideoFilenames, hlsFilenames } +} + +async function check1WebSeed (videoUUID?: string) { + if (!videoUUID) videoUUID = video1Server2.uuid + + const webseeds = [ + `${servers[1].url}/static/web-videos/` + ] + + for (const server of servers) { + // With token to avoid issues with video follow constraints + const video = await server.videos.getWithToken({ id: videoUUID }) + + for (const f of video.files) { + await checkMagnetWebseeds(f, webseeds, server) + } + } + + await ensureSameFilenames(videoUUID) +} + +async function check2Webseeds (videoUUID?: string) { + if (!videoUUID) videoUUID = video1Server2.uuid + + const webseeds = [ + `${servers[0].url}/static/redundancy/`, + `${servers[1].url}/static/web-videos/` + ] + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + for (const file of video.files) { + await checkMagnetWebseeds(file, webseeds, server) + } + } + + const { webVideoFilenames } = await ensureSameFilenames(videoUUID) + + const directories = [ + servers[0].getDirectoryPath('redundancy'), + servers[1].getDirectoryPath('web-videos') + ] + + for (const directory of directories) { + const files = await readdir(directory) + expect(files).to.have.length.at.least(4) + + // Ensure we files exist on disk + expect(files.find(f => webVideoFilenames.includes(f))).to.exist + } +} + +async function check0PlaylistRedundancies (videoUUID?: string) { + if (!videoUUID) videoUUID = video1Server2.uuid + + for (const server of servers) { + // With token to avoid issues with video follow constraints + const video = await server.videos.getWithToken({ id: videoUUID }) + + expect(video.streamingPlaylists).to.be.an('array') + expect(video.streamingPlaylists).to.have.lengthOf(1) + expect(video.streamingPlaylists[0].redundancies).to.have.lengthOf(0) + } + + await ensureSameFilenames(videoUUID) +} + +async function check1PlaylistRedundancies (videoUUID?: string) { + if (!videoUUID) videoUUID = video1Server2.uuid + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.streamingPlaylists).to.have.lengthOf(1) + expect(video.streamingPlaylists[0].redundancies).to.have.lengthOf(1) + + const redundancy = video.streamingPlaylists[0].redundancies[0] + + expect(redundancy.baseUrl).to.equal(servers[0].url + '/static/redundancy/hls/' + videoUUID) + } + + const baseUrlPlaylist = servers[1].url + '/static/streaming-playlists/hls/' + videoUUID + const baseUrlSegment = servers[0].url + '/static/redundancy/hls/' + videoUUID + + const video = await servers[0].videos.get({ id: videoUUID }) + const hlsPlaylist = video.streamingPlaylists[0] + + for (const resolution of [ 240, 360, 480, 720 ]) { + await checkSegmentHash({ server: servers[1], baseUrlPlaylist, baseUrlSegment, resolution, hlsPlaylist }) + } + + const { hlsFilenames } = await ensureSameFilenames(videoUUID) + + const directories = [ + servers[0].getDirectoryPath('redundancy/hls'), + servers[1].getDirectoryPath('streaming-playlists/hls') + ] + + for (const directory of directories) { + const files = await readdir(join(directory, videoUUID)) + expect(files).to.have.length.at.least(4) + + // Ensure we files exist on disk + expect(files.find(f => hlsFilenames.includes(f))).to.exist + } +} + +async function checkStatsGlobal (strategy: VideoRedundancyStrategyWithManual) { + let totalSize: number = null + let statsLength = 1 + + if (strategy !== 'manual') { + totalSize = 409600 + statsLength = 2 + } + + const data = await servers[0].stats.get() + expect(data.videosRedundancy).to.have.lengthOf(statsLength) + + const stat = data.videosRedundancy[0] + expect(stat.strategy).to.equal(strategy) + expect(stat.totalSize).to.equal(totalSize) + + return stat +} + +async function checkStatsWith1Redundancy (strategy: VideoRedundancyStrategyWithManual, onlyHls = false) { + const stat = await checkStatsGlobal(strategy) + + expect(stat.totalUsed).to.be.at.least(1).and.below(409601) + expect(stat.totalVideoFiles).to.equal(onlyHls ? 4 : 8) + expect(stat.totalVideos).to.equal(1) +} + +async function checkStatsWithoutRedundancy (strategy: VideoRedundancyStrategyWithManual) { + const stat = await checkStatsGlobal(strategy) + + expect(stat.totalUsed).to.equal(0) + expect(stat.totalVideoFiles).to.equal(0) + expect(stat.totalVideos).to.equal(0) +} + +async function findServerFollows () { + const body = await servers[0].follows.getFollowings({ start: 0, count: 5, sort: '-createdAt' }) + const follows = body.data + const server2 = follows.find(f => f.following.host === `${servers[1].host}`) + const server3 = follows.find(f => f.following.host === `${servers[2].host}`) + + return { server2, server3 } +} + +async function enableRedundancyOnServer1 () { + await servers[0].redundancy.updateRedundancy({ host: servers[1].host, redundancyAllowed: true }) + + const { server2, server3 } = await findServerFollows() + + expect(server3).to.not.be.undefined + expect(server3.following.hostRedundancyAllowed).to.be.false + + expect(server2).to.not.be.undefined + expect(server2.following.hostRedundancyAllowed).to.be.true +} + +async function disableRedundancyOnServer1 () { + await servers[0].redundancy.updateRedundancy({ host: servers[1].host, redundancyAllowed: false }) + + const { server2, server3 } = await findServerFollows() + + expect(server3).to.not.be.undefined + expect(server3.following.hostRedundancyAllowed).to.be.false + + expect(server2).to.not.be.undefined + expect(server2.following.hostRedundancyAllowed).to.be.false +} + +describe('Test videos redundancy', function () { + + describe('With most-views strategy', function () { + const strategy = 'most-views' + + before(function () { + this.timeout(240000) + + return createServers(strategy) + }) + + it('Should have 1 webseed on the first video', async function () { + await check1WebSeed() + await check0PlaylistRedundancies() + await checkStatsWithoutRedundancy(strategy) + }) + + it('Should enable redundancy on server 1', function () { + return enableRedundancyOnServer1() + }) + + it('Should have 2 webseeds on the first video', async function () { + this.timeout(80000) + + await waitJobs(servers) + await servers[0].servers.waitUntilLog('Duplicated ', 5) + await waitJobs(servers) + + await check2Webseeds() + await check1PlaylistRedundancies() + await checkStatsWith1Redundancy(strategy) + }) + + it('Should undo redundancy on server 1 and remove duplicated videos', async function () { + this.timeout(80000) + + await disableRedundancyOnServer1() + + await waitJobs(servers) + await wait(5000) + + await check1WebSeed() + await check0PlaylistRedundancies() + + await checkVideoFilesWereRemoved({ server: servers[0], video: video1Server2, onlyVideoFiles: true }) + }) + + after(async function () { + return cleanupTests(servers) + }) + }) + + describe('With trending strategy', function () { + const strategy = 'trending' + + before(function () { + this.timeout(240000) + + return createServers(strategy) + }) + + it('Should have 1 webseed on the first video', async function () { + await check1WebSeed() + await check0PlaylistRedundancies() + await checkStatsWithoutRedundancy(strategy) + }) + + it('Should enable redundancy on server 1', function () { + return enableRedundancyOnServer1() + }) + + it('Should have 2 webseeds on the first video', async function () { + this.timeout(80000) + + await waitJobs(servers) + await servers[0].servers.waitUntilLog('Duplicated ', 5) + await waitJobs(servers) + + await check2Webseeds() + await check1PlaylistRedundancies() + await checkStatsWith1Redundancy(strategy) + }) + + it('Should unfollow server 3 and keep duplicated videos', async function () { + this.timeout(80000) + + await servers[0].follows.unfollow({ target: servers[2] }) + + await waitJobs(servers) + await wait(5000) + + await check2Webseeds() + await check1PlaylistRedundancies() + await checkStatsWith1Redundancy(strategy) + }) + + it('Should unfollow server 2 and remove duplicated videos', async function () { + this.timeout(80000) + + await servers[0].follows.unfollow({ target: servers[1] }) + + await waitJobs(servers) + await wait(5000) + + await check1WebSeed() + await check0PlaylistRedundancies() + + await checkVideoFilesWereRemoved({ server: servers[0], video: video1Server2, onlyVideoFiles: true }) + }) + + after(async function () { + await cleanupTests(servers) + }) + }) + + describe('With recently added strategy', function () { + const strategy = 'recently-added' + + before(function () { + this.timeout(240000) + + return createServers(strategy, { min_views: 3 }) + }) + + it('Should have 1 webseed on the first video', async function () { + await check1WebSeed() + await check0PlaylistRedundancies() + await checkStatsWithoutRedundancy(strategy) + }) + + it('Should enable redundancy on server 1', function () { + return enableRedundancyOnServer1() + }) + + it('Should still have 1 webseed on the first video', async function () { + this.timeout(80000) + + await waitJobs(servers) + await wait(15000) + await waitJobs(servers) + + await check1WebSeed() + await check0PlaylistRedundancies() + await checkStatsWithoutRedundancy(strategy) + }) + + it('Should view 2 times the first video to have > min_views config', async function () { + this.timeout(80000) + + await servers[0].views.simulateView({ id: video1Server2.uuid }) + await servers[2].views.simulateView({ id: video1Server2.uuid }) + + await wait(10000) + await waitJobs(servers) + }) + + it('Should have 2 webseeds on the first video', async function () { + this.timeout(80000) + + await waitJobs(servers) + await servers[0].servers.waitUntilLog('Duplicated ', 5) + await waitJobs(servers) + + await check2Webseeds() + await check1PlaylistRedundancies() + await checkStatsWith1Redundancy(strategy) + }) + + it('Should remove the video and the redundancy files', async function () { + this.timeout(20000) + + await saveVideoInServers(servers, video1Server2.uuid) + await servers[1].videos.remove({ id: video1Server2.uuid }) + + await waitJobs(servers) + + for (const server of servers) { + await checkVideoFilesWereRemoved({ server, video: server.store.videoDetails }) + } + }) + + after(async function () { + await cleanupTests(servers) + }) + }) + + describe('With only HLS files', function () { + const strategy = 'recently-added' + + before(async function () { + this.timeout(240000) + + await createServers(strategy, { min_views: 3 }, false) + }) + + it('Should have 0 playlist redundancy on the first video', async function () { + await check1WebSeed() + await check0PlaylistRedundancies() + }) + + it('Should enable redundancy on server 1', function () { + return enableRedundancyOnServer1() + }) + + it('Should still have 0 redundancy on the first video', async function () { + this.timeout(80000) + + await waitJobs(servers) + await wait(15000) + await waitJobs(servers) + + await check0PlaylistRedundancies() + await checkStatsWithoutRedundancy(strategy) + }) + + it('Should have 1 redundancy on the first video', async function () { + this.timeout(160000) + + await servers[0].views.simulateView({ id: video1Server2.uuid }) + await servers[2].views.simulateView({ id: video1Server2.uuid }) + + await wait(10000) + await waitJobs(servers) + + await waitJobs(servers) + await servers[0].servers.waitUntilLog('Duplicated ', 1) + await waitJobs(servers) + + await check1PlaylistRedundancies() + await checkStatsWith1Redundancy(strategy, true) + }) + + it('Should remove the video and the redundancy files', async function () { + this.timeout(20000) + + await saveVideoInServers(servers, video1Server2.uuid) + await servers[1].videos.remove({ id: video1Server2.uuid }) + + await waitJobs(servers) + + for (const server of servers) { + await checkVideoFilesWereRemoved({ server, video: server.store.videoDetails }) + } + }) + + after(async function () { + await cleanupTests(servers) + }) + }) + + describe('With manual strategy', function () { + before(function () { + this.timeout(240000) + + return createServers(null) + }) + + it('Should have 1 webseed on the first video', async function () { + await check1WebSeed() + await check0PlaylistRedundancies() + await checkStatsWithoutRedundancy('manual') + }) + + it('Should create a redundancy on first video', async function () { + await servers[0].redundancy.addVideo({ videoId: video1Server2.id }) + }) + + it('Should have 2 webseeds on the first video', async function () { + this.timeout(80000) + + await waitJobs(servers) + await servers[0].servers.waitUntilLog('Duplicated ', 5) + await waitJobs(servers) + + await check2Webseeds() + await check1PlaylistRedundancies() + await checkStatsWith1Redundancy('manual') + }) + + it('Should manually remove redundancies on server 1 and remove duplicated videos', async function () { + this.timeout(80000) + + const body = await servers[0].redundancy.listVideos({ target: 'remote-videos' }) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + + const video = videos[0] + + for (const r of video.redundancies.files.concat(video.redundancies.streamingPlaylists)) { + await servers[0].redundancy.removeVideo({ redundancyId: r.id }) + } + + await waitJobs(servers) + await wait(5000) + + await check1WebSeed() + await check0PlaylistRedundancies() + + await checkVideoFilesWereRemoved({ server: servers[0], video: video1Server2, onlyVideoFiles: true }) + }) + + after(async function () { + await cleanupTests(servers) + }) + }) + + describe('Test expiration', function () { + const strategy = 'recently-added' + + async function checkContains (servers: PeerTubeServer[], str: string) { + for (const server of servers) { + const video = await server.videos.get({ id: video1Server2.uuid }) + + for (const f of video.files) { + expect(f.magnetUri).to.contain(str) + } + } + } + + async function checkNotContains (servers: PeerTubeServer[], str: string) { + for (const server of servers) { + const video = await server.videos.get({ id: video1Server2.uuid }) + + for (const f of video.files) { + expect(f.magnetUri).to.not.contain(str) + } + } + } + + before(async function () { + this.timeout(240000) + + await createServers(strategy, { min_lifetime: '7 seconds', min_views: 0 }) + + await enableRedundancyOnServer1() + }) + + it('Should still have 2 webseeds after 10 seconds', async function () { + this.timeout(80000) + + await wait(10000) + + try { + await checkContains(servers, 'http%3A%2F%2F' + servers[0].hostname + '%3A' + servers[0].port) + } catch { + // Maybe a server deleted a redundancy in the scheduler + await wait(2000) + + await checkContains(servers, 'http%3A%2F%2F' + servers[0].hostname + '%3A' + servers[0].port) + } + }) + + it('Should stop server 1 and expire video redundancy', async function () { + this.timeout(80000) + + await killallServers([ servers[0] ]) + + await wait(15000) + + await checkNotContains([ servers[1], servers[2] ], 'http%3A%2F%2F' + servers[0].port + '%3A' + servers[0].port) + }) + + after(async function () { + await cleanupTests(servers) + }) + }) + + describe('Test file replacement', function () { + let video2Server2UUID: string + const strategy = 'recently-added' + + before(async function () { + this.timeout(240000) + + await createServers(strategy, { min_lifetime: '7 seconds', min_views: 0 }) + + await enableRedundancyOnServer1() + + await waitJobs(servers) + await servers[0].servers.waitUntilLog('Duplicated ', 5) + await waitJobs(servers) + + await check2Webseeds() + await check1PlaylistRedundancies() + await checkStatsWith1Redundancy(strategy) + + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'video 2 server 2', privacy: VideoPrivacy.PRIVATE } }) + video2Server2UUID = uuid + + // Wait transcoding before federation + await waitJobs(servers) + + await servers[1].videos.update({ id: video2Server2UUID, attributes: { privacy: VideoPrivacy.PUBLIC } }) + }) + + it('Should cache video 2 webseeds on the first video', async function () { + this.timeout(240000) + + await waitJobs(servers) + + let checked = false + + while (checked === false) { + await wait(1000) + + try { + await check1WebSeed() + await check0PlaylistRedundancies() + + await check2Webseeds(video2Server2UUID) + await check1PlaylistRedundancies(video2Server2UUID) + + checked = true + } catch { + checked = false + } + } + }) + + it('Should disable strategy and remove redundancies', async function () { + this.timeout(80000) + + await waitJobs(servers) + + await killallServers([ servers[0] ]) + await servers[0].run({ + redundancy: { + videos: { + check_interval: '1 second', + strategies: [] + } + } + }) + + await waitJobs(servers) + + await checkVideoFilesWereRemoved({ server: servers[0], video: video1Server2, onlyVideoFiles: true }) + }) + + after(async function () { + await cleanupTests(servers) + }) + }) +}) diff --git a/packages/tests/src/api/runners/index.ts b/packages/tests/src/api/runners/index.ts new file mode 100644 index 000000000..441ddc874 --- /dev/null +++ b/packages/tests/src/api/runners/index.ts @@ -0,0 +1,5 @@ +export * from './runner-common.js' +export * from './runner-live-transcoding.js' +export * from './runner-socket.js' +export * from './runner-studio-transcoding.js' +export * from './runner-vod-transcoding.js' diff --git a/packages/tests/src/api/runners/runner-common.ts b/packages/tests/src/api/runners/runner-common.ts new file mode 100644 index 000000000..53ea321d0 --- /dev/null +++ b/packages/tests/src/api/runners/runner-common.ts @@ -0,0 +1,744 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + Runner, + RunnerJob, + RunnerJobAdmin, + RunnerJobState, + RunnerJobStateType, + RunnerJobVODWebVideoTranscodingPayload, + RunnerRegistrationToken +} from '@peertube/peertube-models' +import { + PeerTubeServer, + cleanupTests, + createSingleServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { expect } from 'chai' + +describe('Test runner common actions', function () { + let server: PeerTubeServer + let registrationToken: string + let runnerToken: string + let jobMaxPriority: string + + before(async function () { + this.timeout(120_000) + + server = await createSingleServer(1, { + remote_runners: { + stalled_jobs: { + vod: '5 seconds' + } + } + }) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableTranscoding({ hls: true, webVideo: true }) + await server.config.enableRemoteTranscoding() + }) + + describe('Managing runner registration tokens', function () { + let base: RunnerRegistrationToken[] + let registrationTokenToDelete: RunnerRegistrationToken + + it('Should have a default registration token', async function () { + const { total, data } = await server.runnerRegistrationTokens.list() + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const token = data[0] + expect(token.id).to.exist + expect(token.createdAt).to.exist + expect(token.updatedAt).to.exist + expect(token.registeredRunnersCount).to.equal(0) + expect(token.registrationToken).to.exist + }) + + it('Should create other registration tokens', async function () { + await server.runnerRegistrationTokens.generate() + await server.runnerRegistrationTokens.generate() + + const { total, data } = await server.runnerRegistrationTokens.list() + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + }) + + it('Should list registration tokens', async function () { + { + const { total, data } = await server.runnerRegistrationTokens.list({ sort: 'createdAt' }) + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + expect(new Date(data[0].createdAt)).to.be.below(new Date(data[1].createdAt)) + expect(new Date(data[1].createdAt)).to.be.below(new Date(data[2].createdAt)) + + base = data + + registrationTokenToDelete = data[0] + registrationToken = data[1].registrationToken + } + + { + const { total, data } = await server.runnerRegistrationTokens.list({ sort: '-createdAt', start: 2, count: 1 }) + expect(total).to.equal(3) + expect(data).to.have.lengthOf(1) + expect(data[0].registrationToken).to.equal(base[0].registrationToken) + } + }) + + it('Should have appropriate registeredRunnersCount for registration tokens', async function () { + await server.runners.register({ name: 'to delete 1', registrationToken: registrationTokenToDelete.registrationToken }) + await server.runners.register({ name: 'to delete 2', registrationToken: registrationTokenToDelete.registrationToken }) + + const { data } = await server.runnerRegistrationTokens.list() + + for (const d of data) { + if (d.registrationToken === registrationTokenToDelete.registrationToken) { + expect(d.registeredRunnersCount).to.equal(2) + } else { + expect(d.registeredRunnersCount).to.equal(0) + } + } + + const { data: runners } = await server.runners.list() + expect(runners).to.have.lengthOf(2) + }) + + it('Should delete a registration token', async function () { + await server.runnerRegistrationTokens.delete({ id: registrationTokenToDelete.id }) + + const { total, data } = await server.runnerRegistrationTokens.list({ sort: 'createdAt' }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + for (const d of data) { + expect(d.registeredRunnersCount).to.equal(0) + expect(d.registrationToken).to.not.equal(registrationTokenToDelete.registrationToken) + } + }) + + it('Should have removed runners of this registration token', async function () { + const { data: runners } = await server.runners.list() + expect(runners).to.have.lengthOf(0) + }) + }) + + describe('Managing runners', function () { + let toDelete: Runner + + it('Should not have runners available', async function () { + const { total, data } = await server.runners.list() + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + }) + + it('Should register runners', async function () { + const now = new Date() + + const result = await server.runners.register({ + name: 'runner 1', + description: 'my super runner 1', + registrationToken + }) + expect(result.runnerToken).to.exist + runnerToken = result.runnerToken + + await server.runners.register({ + name: 'runner 2', + registrationToken + }) + + const { total, data } = await server.runners.list({ sort: 'createdAt' }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + for (const d of data) { + expect(d.id).to.exist + expect(d.createdAt).to.exist + expect(d.updatedAt).to.exist + expect(new Date(d.createdAt)).to.be.above(now) + expect(new Date(d.updatedAt)).to.be.above(now) + expect(new Date(d.lastContact)).to.be.above(now) + expect(d.ip).to.exist + } + + expect(data[0].name).to.equal('runner 1') + expect(data[0].description).to.equal('my super runner 1') + + expect(data[1].name).to.equal('runner 2') + expect(data[1].description).to.be.null + + toDelete = data[1] + }) + + it('Should list runners', async function () { + const { total, data } = await server.runners.list({ sort: '-createdAt', start: 1, count: 1 }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('runner 1') + }) + + it('Should delete a runner', async function () { + await server.runners.delete({ id: toDelete.id }) + + const { total, data } = await server.runners.list() + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('runner 1') + }) + + it('Should unregister a runner', async function () { + const registered = await server.runners.autoRegisterRunner() + + { + const { total, data } = await server.runners.list() + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + } + + await server.runners.unregister({ runnerToken: registered }) + + { + const { total, data } = await server.runners.list() + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('runner 1') + } + }) + }) + + describe('Managing runner jobs', function () { + let jobUUID: string + let jobToken: string + let lastRunnerContact: Date + let failedJob: RunnerJob + + async function checkMainJobState ( + mainJobState: RunnerJobStateType, + otherJobStates: RunnerJobStateType[] = [ RunnerJobState.PENDING, RunnerJobState.WAITING_FOR_PARENT_JOB ] + ) { + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + + for (const job of data) { + if (job.uuid === jobUUID) { + expect(job.state.id).to.equal(mainJobState) + } else { + expect(otherJobStates).to.include(job.state.id) + } + } + } + + function getMainJob () { + return server.runnerJobs.getJob({ uuid: jobUUID }) + } + + describe('List jobs', function () { + + it('Should not have jobs', async function () { + const { total, data } = await server.runnerJobs.list() + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + }) + + it('Should upload a video and have available jobs', async function () { + await server.videos.quickUpload({ name: 'to transcode' }) + await waitJobs([ server ]) + + const { total, data } = await server.runnerJobs.list() + + expect(data).to.have.lengthOf(10) + expect(total).to.equal(10) + + for (const job of data) { + expect(job.startedAt).to.not.exist + expect(job.finishedAt).to.not.exist + expect(job.payload).to.exist + expect(job.privatePayload).to.exist + } + + const hlsJobs = data.filter(d => d.type === 'vod-hls-transcoding') + const webVideoJobs = data.filter(d => d.type === 'vod-web-video-transcoding') + + expect(hlsJobs).to.have.lengthOf(5) + expect(webVideoJobs).to.have.lengthOf(5) + + const pendingJobs = data.filter(d => d.state.id === RunnerJobState.PENDING) + const waitingJobs = data.filter(d => d.state.id === RunnerJobState.WAITING_FOR_PARENT_JOB) + + expect(pendingJobs).to.have.lengthOf(1) + expect(waitingJobs).to.have.lengthOf(9) + }) + + it('Should upload another video and list/sort jobs', async function () { + await server.videos.quickUpload({ name: 'to transcode 2' }) + await waitJobs([ server ]) + + { + const { total, data } = await server.runnerJobs.list({ start: 0, count: 30 }) + + expect(data).to.have.lengthOf(20) + expect(total).to.equal(20) + + jobUUID = data[16].uuid + } + + { + const { total, data } = await server.runnerJobs.list({ start: 3, count: 1, sort: 'createdAt' }) + expect(total).to.equal(20) + + expect(data).to.have.lengthOf(1) + expect(data[0].uuid).to.equal(jobUUID) + } + + { + let previousPriority = Infinity + const { total, data } = await server.runnerJobs.list({ start: 0, count: 100, sort: '-priority' }) + expect(total).to.equal(20) + + for (const job of data) { + expect(job.priority).to.be.at.most(previousPriority) + previousPriority = job.priority + + if (job.state.id === RunnerJobState.PENDING) { + jobMaxPriority = job.uuid + } + } + } + }) + + it('Should search jobs', async function () { + { + const { total, data } = await server.runnerJobs.list({ search: jobUUID }) + + expect(data).to.have.lengthOf(1) + expect(total).to.equal(1) + + expect(data[0].uuid).to.equal(jobUUID) + } + + { + const { total, data } = await server.runnerJobs.list({ search: 'toto' }) + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + } + + { + const { total, data } = await server.runnerJobs.list({ search: 'hls' }) + + expect(data).to.not.have.lengthOf(0) + expect(total).to.not.equal(0) + + for (const job of data) { + expect(job.type).to.include('hls') + } + } + }) + + it('Should filter jobs', async function () { + { + const { total, data } = await server.runnerJobs.list({ stateOneOf: [ RunnerJobState.WAITING_FOR_PARENT_JOB ] }) + + expect(data).to.not.have.lengthOf(0) + expect(total).to.not.equal(0) + + for (const job of data) { + expect(job.state.label).to.equal('Waiting for parent job to finish') + } + } + + { + const { total, data } = await server.runnerJobs.list({ stateOneOf: [ RunnerJobState.COMPLETED ] }) + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + } + }) + }) + + describe('Accept/update/abort/process a job', function () { + + it('Should request available jobs', async function () { + lastRunnerContact = new Date() + + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + + // Only optimize jobs are available + expect(availableJobs).to.have.lengthOf(2) + + for (const job of availableJobs) { + expect(job.uuid).to.exist + expect(job.payload.input).to.exist + expect((job.payload as RunnerJobVODWebVideoTranscodingPayload).output).to.exist + + expect((job as RunnerJobAdmin).privatePayload).to.not.exist + } + + const hlsJobs = availableJobs.filter(d => d.type === 'vod-hls-transcoding') + const webVideoJobs = availableJobs.filter(d => d.type === 'vod-web-video-transcoding') + + expect(hlsJobs).to.have.lengthOf(0) + expect(webVideoJobs).to.have.lengthOf(2) + + jobUUID = webVideoJobs[0].uuid + }) + + it('Should have sorted available jobs by priority', async function () { + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + + expect(availableJobs[0].uuid).to.equal(jobMaxPriority) + }) + + it('Should have last runner contact updated', async function () { + await wait(1000) + + const { data } = await server.runners.list({ sort: 'createdAt' }) + expect(new Date(data[0].lastContact)).to.be.above(lastRunnerContact) + }) + + it('Should accept a job', async function () { + const startedAt = new Date() + + const { job } = await server.runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + const checkProcessingJob = (job: RunnerJob & { jobToken?: string }, fromAccept: boolean) => { + expect(job.uuid).to.equal(jobUUID) + + expect(job.type).to.equal('vod-web-video-transcoding') + expect(job.state.label).to.equal('Processing') + expect(job.state.id).to.equal(RunnerJobState.PROCESSING) + + expect(job.runner).to.exist + expect(job.runner.name).to.equal('runner 1') + expect(job.runner.description).to.equal('my super runner 1') + + expect(job.progress).to.be.null + + expect(job.startedAt).to.exist + expect(new Date(job.startedAt)).to.be.above(startedAt) + + expect(job.finishedAt).to.not.exist + + expect(job.failures).to.equal(0) + + expect(job.payload).to.exist + + if (fromAccept) { + expect(job.jobToken).to.exist + expect((job as RunnerJobAdmin).privatePayload).to.not.exist + } else { + expect(job.jobToken).to.not.exist + expect((job as RunnerJobAdmin).privatePayload).to.exist + } + } + + checkProcessingJob(job, true) + + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + + const processingJob = data.find(j => j.uuid === jobUUID) + checkProcessingJob(processingJob, false) + + await checkMainJobState(RunnerJobState.PROCESSING) + }) + + it('Should update a job', async function () { + await server.runnerJobs.update({ runnerToken, jobUUID, jobToken, progress: 53 }) + + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + + for (const job of data) { + if (job.state.id === RunnerJobState.PROCESSING) { + expect(job.progress).to.equal(53) + } else { + expect(job.progress).to.be.null + } + } + }) + + it('Should abort a job', async function () { + await server.runnerJobs.abort({ runnerToken, jobUUID, jobToken, reason: 'for tests' }) + + await checkMainJobState(RunnerJobState.PENDING) + + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + for (const job of data) { + expect(job.progress).to.be.null + } + }) + + it('Should accept the same job again and post a success', async function () { + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + expect(availableJobs.find(j => j.uuid === jobUUID)).to.exist + + const { job } = await server.runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + await checkMainJobState(RunnerJobState.PROCESSING) + + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + + for (const job of data) { + expect(job.progress).to.be.null + } + + const payload = { + videoFile: 'video_short.mp4' + } + + await server.runnerJobs.success({ runnerToken, jobUUID, jobToken, payload }) + }) + + it('Should not have available jobs anymore', async function () { + await checkMainJobState(RunnerJobState.COMPLETED) + + const job = await getMainJob() + expect(job.finishedAt).to.exist + + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + expect(availableJobs.find(j => j.uuid === jobUUID)).to.not.exist + }) + }) + + describe('Error job', function () { + + it('Should accept another job and post an error', async function () { + await server.runnerJobs.cancelAllJobs() + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + jobUUID = availableJobs[0].uuid + + const { job } = await server.runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + await server.runnerJobs.error({ runnerToken, jobUUID, jobToken, message: 'Error' }) + }) + + it('Should have job failures increased', async function () { + const job = await getMainJob() + expect(job.state.id).to.equal(RunnerJobState.PENDING) + expect(job.failures).to.equal(1) + expect(job.error).to.be.null + expect(job.progress).to.be.null + expect(job.finishedAt).to.not.exist + }) + + it('Should error a job when job attempts is too big', async function () { + for (let i = 0; i < 4; i++) { + const { job } = await server.runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + await server.runnerJobs.error({ runnerToken, jobUUID, jobToken, message: 'Error ' + i }) + } + + const job = await getMainJob() + expect(job.failures).to.equal(5) + expect(job.state.id).to.equal(RunnerJobState.ERRORED) + expect(job.state.label).to.equal('Errored') + expect(job.error).to.equal('Error 3') + expect(job.progress).to.be.null + expect(job.finishedAt).to.exist + + failedJob = job + }) + + it('Should have failed children jobs too', async function () { + const { data } = await server.runnerJobs.list({ count: 50, sort: '-updatedAt' }) + + const children = data.filter(j => j.parent?.uuid === failedJob.uuid) + expect(children).to.have.lengthOf(9) + + for (const child of children) { + expect(child.parent.uuid).to.equal(failedJob.uuid) + expect(child.parent.type).to.equal(failedJob.type) + expect(child.parent.state.id).to.equal(failedJob.state.id) + expect(child.parent.state.label).to.equal(failedJob.state.label) + + expect(child.state.id).to.equal(RunnerJobState.PARENT_ERRORED) + expect(child.state.label).to.equal('Parent job failed') + } + }) + }) + + describe('Cancel', function () { + + it('Should cancel a pending job', async function () { + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + { + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + + const pendingJob = data.find(j => j.state.id === RunnerJobState.PENDING) + jobUUID = pendingJob.uuid + + await server.runnerJobs.cancelByAdmin({ jobUUID }) + } + + { + const job = await getMainJob() + expect(job.state.id).to.equal(RunnerJobState.CANCELLED) + expect(job.state.label).to.equal('Cancelled') + } + + { + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + const children = data.filter(j => j.parent?.uuid === jobUUID) + expect(children).to.have.lengthOf(9) + + for (const child of children) { + expect(child.state.id).to.equal(RunnerJobState.PARENT_CANCELLED) + } + } + }) + + it('Should cancel an already accepted job and skip success/error', async function () { + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + jobUUID = availableJobs[0].uuid + + const { job } = await server.runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + await server.runnerJobs.cancelByAdmin({ jobUUID }) + + await server.runnerJobs.abort({ runnerToken, jobUUID, jobToken, reason: 'aborted', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('Remove', function () { + + it('Should remove a pending job', async function () { + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + { + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + + const pendingJob = data.find(j => j.state.id === RunnerJobState.PENDING) + jobUUID = pendingJob.uuid + + await server.runnerJobs.deleteByAdmin({ jobUUID }) + } + + { + const { data } = await server.runnerJobs.list({ count: 10, sort: '-updatedAt' }) + + const parent = data.find(j => j.uuid === jobUUID) + expect(parent).to.not.exist + + const children = data.filter(j => j.parent?.uuid === jobUUID) + expect(children).to.have.lengthOf(0) + } + }) + }) + + describe('Stalled jobs', function () { + + it('Should abort stalled jobs', async function () { + this.timeout(60000) + + await server.videos.quickUpload({ name: 'video' }) + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + const { job: job1 } = await server.runnerJobs.autoAccept({ runnerToken }) + const { job: stalledJob } = await server.runnerJobs.autoAccept({ runnerToken }) + + for (let i = 0; i < 6; i++) { + await wait(2000) + + await server.runnerJobs.update({ runnerToken, jobToken: job1.jobToken, jobUUID: job1.uuid }) + } + + const refreshedJob1 = await server.runnerJobs.getJob({ uuid: job1.uuid }) + const refreshedStalledJob = await server.runnerJobs.getJob({ uuid: stalledJob.uuid }) + + expect(refreshedJob1.state.id).to.equal(RunnerJobState.PROCESSING) + expect(refreshedStalledJob.state.id).to.equal(RunnerJobState.PENDING) + }) + }) + + describe('Rate limit', function () { + + before(async function () { + this.timeout(60000) + + await server.kill() + + await server.run({ + rates_limit: { + api: { + max: 10 + } + } + }) + }) + + it('Should rate limit an unknown runner, but not a registered one', async function () { + this.timeout(60000) + + await server.videos.quickUpload({ name: 'video' }) + await waitJobs([ server ]) + + const { job } = await server.runnerJobs.autoAccept({ runnerToken }) + + for (let i = 0; i < 20; i++) { + try { + await server.runnerJobs.request({ runnerToken }) + await server.runnerJobs.update({ runnerToken, jobToken: job.jobToken, jobUUID: job.uuid }) + } catch {} + } + + // Invalid + { + await server.runnerJobs.request({ runnerToken: 'toto', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 }) + await server.runnerJobs.update({ + runnerToken: 'toto', + jobToken: job.jobToken, + jobUUID: job.uuid, + expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 + }) + } + + // Not provided + { + await server.runnerJobs.request({ runnerToken: undefined, expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 }) + await server.runnerJobs.update({ + runnerToken: undefined, + jobToken: job.jobToken, + jobUUID: job.uuid, + expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 + }) + } + + // Registered + { + await server.runnerJobs.request({ runnerToken }) + await server.runnerJobs.update({ runnerToken, jobToken: job.jobToken, jobUUID: job.uuid }) + } + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/runners/runner-live-transcoding.ts b/packages/tests/src/api/runners/runner-live-transcoding.ts new file mode 100644 index 000000000..20c1e5c2a --- /dev/null +++ b/packages/tests/src/api/runners/runner-live-transcoding.ts @@ -0,0 +1,332 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { FfmpegCommand } from 'fluent-ffmpeg' +import { readFile } from 'fs/promises' +import { wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + LiveRTMPHLSTranscodingUpdatePayload, + LiveVideo, + LiveVideoError, + LiveVideoErrorType, + RunnerJob, + RunnerJobLiveRTMPHLSTranscodingPayload, + Video, + VideoPrivacy, + VideoState +} from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createSingleServer, + makeRawRequest, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + testFfmpegStreamError, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test runner live transcoding', function () { + let server: PeerTubeServer + let runnerToken: string + let baseUrl: string + + before(async function () { + this.timeout(120_000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableRemoteTranscoding() + await server.config.enableTranscoding() + runnerToken = await server.runners.autoRegisterRunner() + + baseUrl = server.url + '/static/streaming-playlists/hls' + }) + + describe('Without transcoding enabled', function () { + + before(async function () { + await server.config.enableLive({ + allowReplay: false, + resolutions: 'min', + transcoding: false + }) + }) + + it('Should not have available jobs', async function () { + this.timeout(120000) + + const { live, video } = await server.live.quickCreate({ permanentLive: true, saveReplay: false, privacy: VideoPrivacy.PUBLIC }) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await server.live.waitUntilPublished({ videoId: video.id }) + + await waitJobs([ server ]) + + const { availableJobs } = await server.runnerJobs.requestLive({ runnerToken }) + expect(availableJobs).to.have.lengthOf(0) + + await stopFfmpeg(ffmpegCommand) + }) + }) + + describe('With transcoding enabled on classic live', function () { + let live: LiveVideo + let video: Video + let ffmpegCommand: FfmpegCommand + let jobUUID: string + let acceptedJob: RunnerJob & { jobToken: string } + + async function testPlaylistFile (fixture: string, expected: string) { + const text = await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/${fixture}` }) + expect(await readFile(buildAbsoluteFixturePath(expected), 'utf-8')).to.equal(text) + + } + + async function testTSFile (fixture: string, expected: string) { + const { body } = await makeRawRequest({ url: `${baseUrl}/${video.uuid}/${fixture}`, expectedStatus: HttpStatusCode.OK_200 }) + expect(await readFile(buildAbsoluteFixturePath(expected))).to.deep.equal(body) + } + + before(async function () { + await server.config.enableLive({ + allowReplay: true, + resolutions: 'max', + transcoding: true + }) + }) + + it('Should publish a a live and have available jobs', async function () { + this.timeout(120000) + + const data = await server.live.quickCreate({ permanentLive: false, saveReplay: false, privacy: VideoPrivacy.PUBLIC }) + live = data.live + video = data.video + + ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await waitJobs([ server ]) + + const job = await server.runnerJobs.requestLiveJob(runnerToken) + jobUUID = job.uuid + + expect(job.type).to.equal('live-rtmp-hls-transcoding') + expect(job.payload.input.rtmpUrl).to.exist + + expect(job.payload.output.toTranscode).to.have.lengthOf(5) + + for (const { resolution, fps } of job.payload.output.toTranscode) { + expect([ 720, 480, 360, 240, 144 ]).to.contain(resolution) + + expect(fps).to.be.above(25) + expect(fps).to.be.below(70) + } + }) + + it('Should update the live with a new chunk', async function () { + this.timeout(120000) + + const { job } = await server.runnerJobs.accept({ jobUUID, runnerToken }) + acceptedJob = job + + { + const payload: LiveRTMPHLSTranscodingUpdatePayload = { + masterPlaylistFile: 'live/master.m3u8', + resolutionPlaylistFile: 'live/0.m3u8', + resolutionPlaylistFilename: '0.m3u8', + type: 'add-chunk', + videoChunkFile: 'live/0-000067.ts', + videoChunkFilename: '0-000067.ts' + } + await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: job.jobToken, payload, progress: 50 }) + + const updatedJob = await server.runnerJobs.getJob({ uuid: job.uuid }) + expect(updatedJob.progress).to.equal(50) + } + + { + const payload: LiveRTMPHLSTranscodingUpdatePayload = { + resolutionPlaylistFile: 'live/1.m3u8', + resolutionPlaylistFilename: '1.m3u8', + type: 'add-chunk', + videoChunkFile: 'live/1-000068.ts', + videoChunkFilename: '1-000068.ts' + } + await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: job.jobToken, payload }) + } + + await wait(1000) + + await testPlaylistFile('master.m3u8', 'live/master.m3u8') + await testPlaylistFile('0.m3u8', 'live/0.m3u8') + await testPlaylistFile('1.m3u8', 'live/1.m3u8') + + await testTSFile('0-000067.ts', 'live/0-000067.ts') + await testTSFile('1-000068.ts', 'live/1-000068.ts') + }) + + it('Should replace existing m3u8 on update', async function () { + this.timeout(120000) + + const payload: LiveRTMPHLSTranscodingUpdatePayload = { + masterPlaylistFile: 'live/1.m3u8', + resolutionPlaylistFilename: '0.m3u8', + resolutionPlaylistFile: 'live/1.m3u8', + type: 'add-chunk', + videoChunkFile: 'live/1-000069.ts', + videoChunkFilename: '1-000068.ts' + } + await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: acceptedJob.jobToken, payload }) + await wait(1000) + + await testPlaylistFile('master.m3u8', 'live/1.m3u8') + await testPlaylistFile('0.m3u8', 'live/1.m3u8') + await testTSFile('1-000068.ts', 'live/1-000069.ts') + }) + + it('Should update the live with removed chunks', async function () { + this.timeout(120000) + + const payload: LiveRTMPHLSTranscodingUpdatePayload = { + resolutionPlaylistFile: 'live/0.m3u8', + resolutionPlaylistFilename: '0.m3u8', + type: 'remove-chunk', + videoChunkFilename: '1-000068.ts' + } + await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: acceptedJob.jobToken, payload }) + + await wait(1000) + + await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/master.m3u8` }) + await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/0.m3u8` }) + await server.streamingPlaylists.get({ url: `${baseUrl}/${video.uuid}/1.m3u8` }) + await makeRawRequest({ url: `${baseUrl}/${video.uuid}/0-000067.ts`, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: `${baseUrl}/${video.uuid}/1-000068.ts`, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should complete the live and save the replay', async function () { + this.timeout(120000) + + for (const segment of [ '0-000069.ts', '0-000070.ts' ]) { + const payload: LiveRTMPHLSTranscodingUpdatePayload = { + masterPlaylistFile: 'live/master.m3u8', + resolutionPlaylistFilename: '0.m3u8', + resolutionPlaylistFile: 'live/0.m3u8', + type: 'add-chunk', + videoChunkFile: 'live/' + segment, + videoChunkFilename: segment + } + await server.runnerJobs.update({ jobUUID, runnerToken, jobToken: acceptedJob.jobToken, payload }) + + await wait(1000) + } + + await waitJobs([ server ]) + + { + const { state } = await server.videos.get({ id: video.uuid }) + expect(state.id).to.equal(VideoState.PUBLISHED) + } + + await stopFfmpeg(ffmpegCommand) + + await server.runnerJobs.success({ jobUUID, runnerToken, jobToken: acceptedJob.jobToken, payload: {} }) + + await wait(1500) + await waitJobs([ server ]) + + { + const { state } = await server.videos.get({ id: video.uuid }) + expect(state.id).to.equal(VideoState.LIVE_ENDED) + + const session = await server.live.findLatestSession({ videoId: video.uuid }) + expect(session.error).to.be.null + } + }) + }) + + describe('With transcoding enabled on cancelled/aborted/errored live', function () { + let live: LiveVideo + let video: Video + let ffmpegCommand: FfmpegCommand + + async function prepare () { + ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await server.runnerJobs.requestLiveJob(runnerToken) + + const { job } = await server.runnerJobs.autoAccept({ runnerToken, type: 'live-rtmp-hls-transcoding' }) + + return job + } + + async function checkSessionError (error: LiveVideoErrorType) { + await wait(1500) + await waitJobs([ server ]) + + const session = await server.live.findLatestSession({ videoId: video.uuid }) + expect(session.error).to.equal(error) + } + + before(async function () { + await server.config.enableLive({ + allowReplay: true, + resolutions: 'max', + transcoding: true + }) + + const data = await server.live.quickCreate({ permanentLive: true, saveReplay: false, privacy: VideoPrivacy.PUBLIC }) + live = data.live + video = data.video + }) + + it('Should abort a running live', async function () { + this.timeout(120000) + + const job = await prepare() + + await Promise.all([ + server.runnerJobs.abort({ jobUUID: job.uuid, runnerToken, jobToken: job.jobToken, reason: 'abort' }), + testFfmpegStreamError(ffmpegCommand, true) + ]) + + // Abort is not supported + await checkSessionError(LiveVideoError.RUNNER_JOB_ERROR) + }) + + it('Should cancel a running live', async function () { + this.timeout(120000) + + const job = await prepare() + + await Promise.all([ + server.runnerJobs.cancelByAdmin({ jobUUID: job.uuid }), + testFfmpegStreamError(ffmpegCommand, true) + ]) + + await checkSessionError(LiveVideoError.RUNNER_JOB_CANCEL) + }) + + it('Should error a running live', async function () { + this.timeout(120000) + + const job = await prepare() + + await Promise.all([ + server.runnerJobs.error({ jobUUID: job.uuid, runnerToken, jobToken: job.jobToken, message: 'error' }), + testFfmpegStreamError(ffmpegCommand, true) + ]) + + await checkSessionError(LiveVideoError.RUNNER_JOB_ERROR) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/runners/runner-socket.ts b/packages/tests/src/api/runners/runner-socket.ts new file mode 100644 index 000000000..726ef084f --- /dev/null +++ b/packages/tests/src/api/runners/runner-socket.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test runner socket', function () { + let server: PeerTubeServer + let runnerToken: string + + before(async function () { + this.timeout(120_000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableTranscoding({ hls: true, webVideo: true }) + await server.config.enableRemoteTranscoding() + runnerToken = await server.runners.autoRegisterRunner() + }) + + it('Should throw an error without runner token', function (done) { + const localSocket = server.socketIO.getRunnersSocket({ runnerToken: null }) + localSocket.on('connect_error', err => { + expect(err.message).to.contain('No runner token provided') + done() + }) + }) + + it('Should throw an error with a bad runner token', function (done) { + const localSocket = server.socketIO.getRunnersSocket({ runnerToken: 'ergag' }) + localSocket.on('connect_error', err => { + expect(err.message).to.contain('Invalid runner token') + done() + }) + }) + + it('Should not send ping if there is no available jobs', async function () { + let pings = 0 + const localSocket = server.socketIO.getRunnersSocket({ runnerToken }) + localSocket.on('available-jobs', () => pings++) + + expect(pings).to.equal(0) + }) + + it('Should send a ping on available job', async function () { + let pings = 0 + const localSocket = server.socketIO.getRunnersSocket({ runnerToken }) + localSocket.on('available-jobs', () => pings++) + + await server.videos.quickUpload({ name: 'video1' }) + await waitJobs([ server ]) + + // eslint-disable-next-line no-unmodified-loop-condition + while (pings !== 1) { + await wait(500) + } + + await server.videos.quickUpload({ name: 'video2' }) + await waitJobs([ server ]) + + // eslint-disable-next-line no-unmodified-loop-condition + while ((pings as number) !== 2) { + await wait(500) + } + + await server.runnerJobs.cancelAllJobs() + }) + + it('Should send a ping when a child is ready', async function () { + let pings = 0 + const localSocket = server.socketIO.getRunnersSocket({ runnerToken }) + localSocket.on('available-jobs', () => pings++) + + await server.videos.quickUpload({ name: 'video3' }) + await waitJobs([ server ]) + + // eslint-disable-next-line no-unmodified-loop-condition + while (pings !== 1) { + await wait(500) + } + + await server.runnerJobs.autoProcessWebVideoJob(runnerToken) + await waitJobs([ server ]) + + // eslint-disable-next-line no-unmodified-loop-condition + while ((pings as number) !== 2) { + await wait(500) + } + }) + + it('Should not send a ping if the ended job does not have a child', async function () { + let pings = 0 + const localSocket = server.socketIO.getRunnersSocket({ runnerToken }) + localSocket.on('available-jobs', () => pings++) + + const { availableJobs } = await server.runnerJobs.request({ runnerToken }) + const job = availableJobs.find(j => j.type === 'vod-web-video-transcoding') + await server.runnerJobs.autoProcessWebVideoJob(runnerToken, job.uuid) + + // Wait for debounce + await wait(1000) + await waitJobs([ server ]) + + expect(pings).to.equal(0) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/runners/runner-studio-transcoding.ts b/packages/tests/src/api/runners/runner-studio-transcoding.ts new file mode 100644 index 000000000..adf6941c3 --- /dev/null +++ b/packages/tests/src/api/runners/runner-studio-transcoding.ts @@ -0,0 +1,169 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { readFile } from 'fs/promises' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + RunnerJobStudioTranscodingPayload, + VideoStudioTranscodingSuccess, + VideoState, + VideoStudioTask, + VideoStudioTaskIntro +} from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + VideoStudioCommand, + waitJobs +} from '@peertube/peertube-server-commands' +import { checkVideoDuration } from '@tests/shared/checks.js' +import { checkPersistentTmpIsEmpty } from '@tests/shared/directories.js' + +describe('Test runner video studio transcoding', function () { + let servers: PeerTubeServer[] = [] + let runnerToken: string + let videoUUID: string + let jobUUID: string + + async function renewStudio (tasks: VideoStudioTask[] = VideoStudioCommand.getComplexTask()) { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) + videoUUID = uuid + + await waitJobs(servers) + + await servers[0].videoStudio.createEditionTasks({ videoId: uuid, tasks }) + await waitJobs(servers) + + const { availableJobs } = await servers[0].runnerJobs.request({ runnerToken }) + expect(availableJobs).to.have.lengthOf(1) + + jobUUID = availableJobs[0].uuid + } + + before(async function () { + this.timeout(120_000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + await servers[0].config.enableStudio() + await servers[0].config.enableRemoteStudio() + + runnerToken = await servers[0].runners.autoRegisterRunner() + }) + + it('Should error a studio transcoding job', async function () { + this.timeout(60000) + + await renewStudio() + + for (let i = 0; i < 5; i++) { + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + const jobToken = job.jobToken + + await servers[0].runnerJobs.error({ runnerToken, jobUUID, jobToken, message: 'Error' }) + } + + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.state.id).to.equal(VideoState.PUBLISHED) + + await checkPersistentTmpIsEmpty(servers[0]) + }) + + it('Should cancel a transcoding job', async function () { + this.timeout(60000) + + await renewStudio() + + await servers[0].runnerJobs.cancelByAdmin({ jobUUID }) + + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.state.id).to.equal(VideoState.PUBLISHED) + + await checkPersistentTmpIsEmpty(servers[0]) + }) + + it('Should execute a remote studio job', async function () { + this.timeout(240_000) + + const tasks = [ + { + name: 'add-outro' as 'add-outro', + options: { + file: 'video_short.webm' + } + }, + { + name: 'add-watermark' as 'add-watermark', + options: { + file: 'custom-thumbnail.png' + } + }, + { + name: 'add-intro' as 'add-intro', + options: { + file: 'video_very_short_240p.mp4' + } + } + ] + + await renewStudio(tasks) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 5) + } + + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + const jobToken = job.jobToken + + expect(job.type === 'video-studio-transcoding') + expect(job.payload.input.videoFileUrl).to.exist + + // Check video input file + { + await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken }) + } + + // Check task files + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i] + const payloadTask = job.payload.tasks[i] + + expect(payloadTask.name).to.equal(task.name) + + const inputFile = await readFile(buildAbsoluteFixturePath(task.options.file)) + + const { body } = await servers[0].runnerJobs.getJobFile({ + url: (payloadTask as VideoStudioTaskIntro).options.file as string, + jobToken, + runnerToken + }) + + expect(body).to.deep.equal(inputFile) + } + + const payload: VideoStudioTranscodingSuccess = { videoFile: 'video_very_short_240p.mp4' } + await servers[0].runnerJobs.success({ runnerToken, jobUUID, jobToken, payload }) + + await waitJobs(servers) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 2) + } + + await checkPersistentTmpIsEmpty(servers[0]) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/runners/runner-vod-transcoding.ts b/packages/tests/src/api/runners/runner-vod-transcoding.ts new file mode 100644 index 000000000..fe1c8f0b2 --- /dev/null +++ b/packages/tests/src/api/runners/runner-vod-transcoding.ts @@ -0,0 +1,545 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { readFile } from 'fs/promises' +import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + HttpStatusCode, + RunnerJobSuccessPayload, + RunnerJobVODAudioMergeTranscodingPayload, + RunnerJobVODHLSTranscodingPayload, + RunnerJobVODPayload, + RunnerJobVODWebVideoTranscodingPayload, + VideoState, + VODAudioMergeTranscodingSuccess, + VODHLSTranscodingSuccess, + VODWebVideoTranscodingSuccess +} from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + makeRawRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +async function processAllJobs (server: PeerTubeServer, runnerToken: string) { + do { + const { availableJobs } = await server.runnerJobs.requestVOD({ runnerToken }) + if (availableJobs.length === 0) break + + const { job } = await server.runnerJobs.accept({ runnerToken, jobUUID: availableJobs[0].uuid }) + + const payload: RunnerJobSuccessPayload = { + videoFile: `video_short_${job.payload.output.resolution}p.mp4`, + resolutionPlaylistFile: `video_short_${job.payload.output.resolution}p.m3u8` + } + await server.runnerJobs.success({ runnerToken, jobUUID: job.uuid, jobToken: job.jobToken, payload }) + } while (true) + + await waitJobs([ server ]) +} + +describe('Test runner VOD transcoding', function () { + let servers: PeerTubeServer[] = [] + let runnerToken: string + + before(async function () { + this.timeout(120_000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableRemoteTranscoding() + runnerToken = await servers[0].runners.autoRegisterRunner() + }) + + describe('Without transcoding', function () { + + before(async function () { + this.timeout(60000) + + await servers[0].config.disableTranscoding() + await servers[0].videos.quickUpload({ name: 'video' }) + + await waitJobs(servers) + }) + + it('Should not have available jobs', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(0) + }) + }) + + describe('With classic transcoding enabled', function () { + + before(async function () { + this.timeout(60000) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + }) + + it('Should error a transcoding job', async function () { + this.timeout(60000) + + await servers[0].runnerJobs.cancelAllJobs() + const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) + await waitJobs(servers) + + const { availableJobs } = await servers[0].runnerJobs.request({ runnerToken }) + const jobUUID = availableJobs[0].uuid + + for (let i = 0; i < 5; i++) { + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + const jobToken = job.jobToken + + await servers[0].runnerJobs.error({ runnerToken, jobUUID, jobToken, message: 'Error' }) + } + + const video = await servers[0].videos.get({ id: uuid }) + expect(video.state.id).to.equal(VideoState.TRANSCODING_FAILED) + }) + + it('Should cancel a transcoding job', async function () { + await servers[0].runnerJobs.cancelAllJobs() + const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) + await waitJobs(servers) + + const { availableJobs } = await servers[0].runnerJobs.request({ runnerToken }) + const jobUUID = availableJobs[0].uuid + + await servers[0].runnerJobs.cancelByAdmin({ jobUUID }) + + const video = await servers[0].videos.get({ id: uuid }) + expect(video.state.id).to.equal(VideoState.PUBLISHED) + }) + }) + + describe('Web video transcoding only', function () { + let videoUUID: string + let jobToken: string + let jobUUID: string + + before(async function () { + this.timeout(60000) + + await servers[0].runnerJobs.cancelAllJobs() + await servers[0].config.enableTranscoding({ hls: false, webVideo: true }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'web video', fixture: 'video_short.webm' }) + videoUUID = uuid + + await waitJobs(servers) + }) + + it('Should have jobs available for remote runners', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(1) + + jobUUID = availableJobs[0].uuid + }) + + it('Should have a valid first transcoding job', async function () { + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + expect(job.type === 'vod-web-video-transcoding') + expect(job.payload.input.videoFileUrl).to.exist + expect(job.payload.output.resolution).to.equal(720) + expect(job.payload.output.fps).to.equal(25) + + const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken }) + const inputFile = await readFile(buildAbsoluteFixturePath('video_short.webm')) + + expect(body).to.deep.equal(inputFile) + }) + + it('Should transcode the max video resolution and send it back to the server', async function () { + this.timeout(60000) + + const payload: VODWebVideoTranscodingSuccess = { + videoFile: 'video_short.mp4' + } + await servers[0].runnerJobs.success({ runnerToken, jobUUID, jobToken, payload }) + + await waitJobs(servers) + }) + + it('Should have the video updated', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.files).to.have.lengthOf(1) + expect(video.streamingPlaylists).to.have.lengthOf(0) + + const { body } = await makeRawRequest({ url: video.files[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + expect(body).to.deep.equal(await readFile(buildAbsoluteFixturePath('video_short.mp4'))) + } + }) + + it('Should have 4 lower resolution to transcode', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(4) + + for (const resolution of [ 480, 360, 240, 144 ]) { + const job = availableJobs.find(j => j.payload.output.resolution === resolution) + expect(job).to.exist + expect(job.type).to.equal('vod-web-video-transcoding') + + if (resolution === 240) jobUUID = job.uuid + } + }) + + it('Should process one of these transcoding jobs', async function () { + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken }) + const inputFile = await readFile(buildAbsoluteFixturePath('video_short.mp4')) + + expect(body).to.deep.equal(inputFile) + + const payload: VODWebVideoTranscodingSuccess = { videoFile: `video_short_${job.payload.output.resolution}p.mp4` } + await servers[0].runnerJobs.success({ runnerToken, jobUUID, jobToken, payload }) + }) + + it('Should process all other jobs', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(3) + + for (const resolution of [ 480, 360, 144 ]) { + const availableJob = availableJobs.find(j => j.payload.output.resolution === resolution) + expect(availableJob).to.exist + jobUUID = availableJob.uuid + + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken }) + const inputFile = await readFile(buildAbsoluteFixturePath('video_short.mp4')) + expect(body).to.deep.equal(inputFile) + + const payload: VODWebVideoTranscodingSuccess = { videoFile: `video_short_${resolution}p.mp4` } + await servers[0].runnerJobs.success({ runnerToken, jobUUID, jobToken, payload }) + } + + await waitJobs(servers) + }) + + it('Should have the video updated', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.files).to.have.lengthOf(5) + expect(video.streamingPlaylists).to.have.lengthOf(0) + + const { body } = await makeRawRequest({ url: video.files[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + expect(body).to.deep.equal(await readFile(buildAbsoluteFixturePath('video_short.mp4'))) + + for (const file of video.files) { + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + } + }) + + it('Should not have available jobs anymore', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(0) + }) + }) + + describe('HLS transcoding only', function () { + let videoUUID: string + let jobToken: string + let jobUUID: string + + before(async function () { + this.timeout(60000) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: false }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'hls video', fixture: 'video_short.webm' }) + videoUUID = uuid + + await waitJobs(servers) + }) + + it('Should run the optimize job', async function () { + this.timeout(60000) + + await servers[0].runnerJobs.autoProcessWebVideoJob(runnerToken) + }) + + it('Should have 5 HLS resolution to transcode', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(5) + + for (const resolution of [ 720, 480, 360, 240, 144 ]) { + const job = availableJobs.find(j => j.payload.output.resolution === resolution) + expect(job).to.exist + expect(job.type).to.equal('vod-hls-transcoding') + + if (resolution === 480) jobUUID = job.uuid + } + }) + + it('Should process one of these transcoding jobs', async function () { + this.timeout(60000) + + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken }) + const inputFile = await readFile(buildAbsoluteFixturePath('video_short.mp4')) + + expect(body).to.deep.equal(inputFile) + + const payload: VODHLSTranscodingSuccess = { + videoFile: 'video_short_480p.mp4', + resolutionPlaylistFile: 'video_short_480p.m3u8' + } + await servers[0].runnerJobs.success({ runnerToken, jobUUID, jobToken, payload }) + + await waitJobs(servers) + }) + + it('Should have the video updated', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.files).to.have.lengthOf(1) + expect(video.streamingPlaylists).to.have.lengthOf(1) + + const hls = video.streamingPlaylists[0] + expect(hls.files).to.have.lengthOf(1) + + await completeCheckHlsPlaylist({ videoUUID, hlsOnly: false, servers, resolutions: [ 480 ] }) + } + }) + + it('Should process all other jobs', async function () { + this.timeout(60000) + + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(4) + + let maxQualityFile = 'video_short.mp4' + + for (const resolution of [ 720, 360, 240, 144 ]) { + const availableJob = availableJobs.find(j => j.payload.output.resolution === resolution) + expect(availableJob).to.exist + jobUUID = availableJob.uuid + + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken }) + const inputFile = await readFile(buildAbsoluteFixturePath(maxQualityFile)) + expect(body).to.deep.equal(inputFile) + + const payload: VODHLSTranscodingSuccess = { + videoFile: `video_short_${resolution}p.mp4`, + resolutionPlaylistFile: `video_short_${resolution}p.m3u8` + } + await servers[0].runnerJobs.success({ runnerToken, jobUUID, jobToken, payload }) + + if (resolution === 720) { + maxQualityFile = 'video_short_720p.mp4' + } + } + + await waitJobs(servers) + }) + + it('Should have the video updated', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.files).to.have.lengthOf(0) + expect(video.streamingPlaylists).to.have.lengthOf(1) + + const hls = video.streamingPlaylists[0] + expect(hls.files).to.have.lengthOf(5) + + await completeCheckHlsPlaylist({ videoUUID, hlsOnly: true, servers, resolutions: [ 720, 480, 360, 240, 144 ] }) + } + }) + + it('Should not have available jobs anymore', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(0) + }) + }) + + describe('Web video and HLS transcoding', function () { + + before(async function () { + this.timeout(60000) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + + await servers[0].videos.quickUpload({ name: 'web video and hls video', fixture: 'video_short.webm' }) + + await waitJobs(servers) + }) + + it('Should process the first optimize job', async function () { + this.timeout(60000) + + await servers[0].runnerJobs.autoProcessWebVideoJob(runnerToken) + }) + + it('Should have 9 jobs to process', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + + expect(availableJobs).to.have.lengthOf(9) + + const webVideoJobs = availableJobs.filter(j => j.type === 'vod-web-video-transcoding') + const hlsJobs = availableJobs.filter(j => j.type === 'vod-hls-transcoding') + + expect(webVideoJobs).to.have.lengthOf(4) + expect(hlsJobs).to.have.lengthOf(5) + }) + + it('Should process all available jobs', async function () { + await processAllJobs(servers[0], runnerToken) + }) + }) + + describe('Audio merge transcoding', function () { + let videoUUID: string + let jobToken: string + let jobUUID: string + + before(async function () { + this.timeout(60000) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + + const attributes = { name: 'audio_with_preview', previewfile: 'custom-preview.jpg', fixture: 'sample.ogg' } + const { uuid } = await servers[0].videos.upload({ attributes, mode: 'legacy' }) + videoUUID = uuid + + await waitJobs(servers) + }) + + it('Should have an audio merge transcoding job', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(1) + + expect(availableJobs[0].type).to.equal('vod-audio-merge-transcoding') + + jobUUID = availableJobs[0].uuid + }) + + it('Should have a valid remote audio merge transcoding job', async function () { + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + expect(job.type === 'vod-audio-merge-transcoding') + expect(job.payload.input.audioFileUrl).to.exist + expect(job.payload.input.previewFileUrl).to.exist + expect(job.payload.output.resolution).to.equal(480) + + { + const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.audioFileUrl, jobToken, runnerToken }) + const inputFile = await readFile(buildAbsoluteFixturePath('sample.ogg')) + expect(body).to.deep.equal(inputFile) + } + + { + const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.previewFileUrl, jobToken, runnerToken }) + + const video = await servers[0].videos.get({ id: videoUUID }) + const { body: inputFile } = await makeGetRequest({ + url: servers[0].url, + path: video.previewPath, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(body).to.deep.equal(inputFile) + } + }) + + it('Should merge the audio', async function () { + this.timeout(60000) + + const payload: VODAudioMergeTranscodingSuccess = { videoFile: 'video_short_480p.mp4' } + await servers[0].runnerJobs.success({ runnerToken, jobUUID, jobToken, payload }) + + await waitJobs(servers) + }) + + it('Should have the video updated', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.files).to.have.lengthOf(1) + expect(video.streamingPlaylists).to.have.lengthOf(0) + + const { body } = await makeRawRequest({ url: video.files[0].fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + expect(body).to.deep.equal(await readFile(buildAbsoluteFixturePath('video_short_480p.mp4'))) + } + }) + + it('Should have 7 lower resolutions to transcode', async function () { + const { availableJobs } = await servers[0].runnerJobs.requestVOD({ runnerToken }) + expect(availableJobs).to.have.lengthOf(7) + + for (const resolution of [ 360, 240, 144 ]) { + const jobs = availableJobs.filter(j => j.payload.output.resolution === resolution) + expect(jobs).to.have.lengthOf(2) + } + + jobUUID = availableJobs.find(j => j.payload.output.resolution === 480).uuid + }) + + it('Should process one other job', async function () { + this.timeout(60000) + + const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID }) + jobToken = job.jobToken + + const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken }) + const inputFile = await readFile(buildAbsoluteFixturePath('video_short_480p.mp4')) + expect(body).to.deep.equal(inputFile) + + const payload: VODHLSTranscodingSuccess = { + videoFile: `video_short_480p.mp4`, + resolutionPlaylistFile: `video_short_480p.m3u8` + } + await servers[0].runnerJobs.success({ runnerToken, jobUUID, jobToken, payload }) + + await waitJobs(servers) + }) + + it('Should have the video updated', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.files).to.have.lengthOf(1) + expect(video.streamingPlaylists).to.have.lengthOf(1) + + const hls = video.streamingPlaylists[0] + expect(hls.files).to.have.lengthOf(1) + + await completeCheckHlsPlaylist({ videoUUID, hlsOnly: false, servers, resolutions: [ 480 ] }) + } + }) + + it('Should process all available jobs', async function () { + await processAllJobs(servers[0], runnerToken) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/search/index.ts b/packages/tests/src/api/search/index.ts new file mode 100644 index 000000000..f4420261d --- /dev/null +++ b/packages/tests/src/api/search/index.ts @@ -0,0 +1,7 @@ +import './search-activitypub-video-playlists.js' +import './search-activitypub-video-channels.js' +import './search-activitypub-videos.js' +import './search-channels.js' +import './search-index.js' +import './search-playlists.js' +import './search-videos.js' diff --git a/packages/tests/src/api/search/search-activitypub-video-channels.ts b/packages/tests/src/api/search/search-activitypub-video-channels.ts new file mode 100644 index 000000000..b63f45b18 --- /dev/null +++ b/packages/tests/src/api/search/search-activitypub-video-channels.ts @@ -0,0 +1,255 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { VideoChannel } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + PeerTubeServer, + SearchCommand, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test ActivityPub video channels search', function () { + let servers: PeerTubeServer[] + let userServer2Token: string + let videoServer2UUID: string + let channelIdServer2: number + let command: SearchCommand + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + { + await servers[0].users.create({ username: 'user1_server1', password: 'password' }) + const channel = { + name: 'channel1_server1', + displayName: 'Channel 1 server 1' + } + await servers[0].channels.create({ attributes: channel }) + } + + { + const user = { username: 'user1_server2', password: 'password' } + await servers[1].users.create({ username: user.username, password: user.password }) + userServer2Token = await servers[1].login.getAccessToken(user) + + const channel = { + name: 'channel1_server2', + displayName: 'Channel 1 server 2' + } + const created = await servers[1].channels.create({ token: userServer2Token, attributes: channel }) + channelIdServer2 = created.id + + const attributes = { name: 'video 1 server 2', channelId: channelIdServer2 } + const { uuid } = await servers[1].videos.upload({ token: userServer2Token, attributes }) + videoServer2UUID = uuid + } + + await waitJobs(servers) + + command = servers[0].search + }) + + it('Should not find a remote video channel', async function () { + this.timeout(15000) + + { + const search = servers[1].url + '/video-channels/channel1_server3' + const body = await command.searchChannels({ search, token: servers[0].accessToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + + { + // Without token + const search = servers[1].url + '/video-channels/channel1_server2' + const body = await command.searchChannels({ search }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should search a local video channel', async function () { + const searches = [ + servers[0].url + '/video-channels/channel1_server1', + 'channel1_server1@' + servers[0].host + ] + + for (const search of searches) { + const body = await command.searchChannels({ search }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('channel1_server1') + expect(body.data[0].displayName).to.equal('Channel 1 server 1') + } + }) + + it('Should search a local video channel with an alternative URL', async function () { + const search = servers[0].url + '/c/channel1_server1' + + for (const token of [ undefined, servers[0].accessToken ]) { + const body = await command.searchChannels({ search, token }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('channel1_server1') + expect(body.data[0].displayName).to.equal('Channel 1 server 1') + } + }) + + it('Should search a local video channel with a query in URL', async function () { + const searches = [ + servers[0].url + '/video-channels/channel1_server1', + servers[0].url + '/c/channel1_server1' + ] + + for (const search of searches) { + for (const token of [ undefined, servers[0].accessToken ]) { + const body = await command.searchChannels({ search: search + '?param=2', token }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('channel1_server1') + expect(body.data[0].displayName).to.equal('Channel 1 server 1') + } + } + }) + + it('Should search a remote video channel with URL or handle', async function () { + const searches = [ + servers[1].url + '/video-channels/channel1_server2', + servers[1].url + '/c/channel1_server2', + servers[1].url + '/c/channel1_server2/videos', + 'channel1_server2@' + servers[1].host + ] + + for (const search of searches) { + const body = await command.searchChannels({ search, token: servers[0].accessToken }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('channel1_server2') + expect(body.data[0].displayName).to.equal('Channel 1 server 2') + } + }) + + it('Should not list this remote video channel', async function () { + const body = await servers[0].channels.list() + expect(body.total).to.equal(3) + expect(body.data).to.have.lengthOf(3) + expect(body.data[0].name).to.equal('channel1_server1') + expect(body.data[1].name).to.equal('user1_server1_channel') + expect(body.data[2].name).to.equal('root_channel') + }) + + it('Should list video channel videos of server 2 without token', async function () { + this.timeout(30000) + + await waitJobs(servers) + + const { total, data } = await servers[0].videos.listByChannel({ + token: null, + handle: 'channel1_server2@' + servers[1].host + }) + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + }) + + it('Should list video channel videos of server 2 with token', async function () { + const { total, data } = await servers[0].videos.listByChannel({ + handle: 'channel1_server2@' + servers[1].host + }) + + expect(total).to.equal(1) + expect(data[0].name).to.equal('video 1 server 2') + }) + + it('Should update video channel of server 2, and refresh it on server 1', async function () { + this.timeout(120000) + + await servers[1].channels.update({ + token: userServer2Token, + channelName: 'channel1_server2', + attributes: { displayName: 'channel updated' } + }) + await servers[1].users.updateMe({ token: userServer2Token, displayName: 'user updated' }) + + await waitJobs(servers) + // Expire video channel + await wait(10000) + + const search = servers[1].url + '/video-channels/channel1_server2' + const body = await command.searchChannels({ search, token: servers[0].accessToken }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const videoChannel: VideoChannel = body.data[0] + expect(videoChannel.displayName).to.equal('channel updated') + + // We don't return the owner account for now + // expect(videoChannel.ownerAccount.displayName).to.equal('user updated') + }) + + it('Should update and add a video on server 2, and update it on server 1 after a search', async function () { + this.timeout(120000) + + await servers[1].videos.update({ token: userServer2Token, id: videoServer2UUID, attributes: { name: 'video 1 updated' } }) + await servers[1].videos.upload({ token: userServer2Token, attributes: { name: 'video 2 server 2', channelId: channelIdServer2 } }) + + await waitJobs(servers) + + // Expire video channel + await wait(10000) + + const search = servers[1].url + '/video-channels/channel1_server2' + await command.searchChannels({ search, token: servers[0].accessToken }) + + await waitJobs(servers) + + const handle = 'channel1_server2@' + servers[1].host + const { total, data } = await servers[0].videos.listByChannel({ handle, sort: '-createdAt' }) + + expect(total).to.equal(2) + expect(data[0].name).to.equal('video 2 server 2') + expect(data[1].name).to.equal('video 1 updated') + }) + + it('Should delete video channel of server 2, and delete it on server 1', async function () { + this.timeout(120000) + + await servers[1].channels.delete({ token: userServer2Token, channelName: 'channel1_server2' }) + + await waitJobs(servers) + // Expire video + await wait(10000) + + const search = servers[1].url + '/video-channels/channel1_server2' + const body = await command.searchChannels({ search, token: servers[0].accessToken }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/search/search-activitypub-video-playlists.ts b/packages/tests/src/api/search/search-activitypub-video-playlists.ts new file mode 100644 index 000000000..33ecfd8e7 --- /dev/null +++ b/packages/tests/src/api/search/search-activitypub-video-playlists.ts @@ -0,0 +1,214 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { VideoPlaylistPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + PeerTubeServer, + SearchCommand, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test ActivityPub playlists search', function () { + let servers: PeerTubeServer[] + let playlistServer1UUID: string + let playlistServer2UUID: string + let video2Server2: string + + let command: SearchCommand + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + { + const video1 = (await servers[0].videos.quickUpload({ name: 'video 1' })).uuid + const video2 = (await servers[0].videos.quickUpload({ name: 'video 2' })).uuid + + const attributes = { + displayName: 'playlist 1 on server 1', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[0].store.channel.id + } + const created = await servers[0].playlists.create({ attributes }) + playlistServer1UUID = created.uuid + + for (const videoId of [ video1, video2 ]) { + await servers[0].playlists.addElement({ playlistId: playlistServer1UUID, attributes: { videoId } }) + } + } + + { + const videoId = (await servers[1].videos.quickUpload({ name: 'video 1' })).uuid + video2Server2 = (await servers[1].videos.quickUpload({ name: 'video 2' })).uuid + + const attributes = { + displayName: 'playlist 1 on server 2', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[1].store.channel.id + } + const created = await servers[1].playlists.create({ attributes }) + playlistServer2UUID = created.uuid + + await servers[1].playlists.addElement({ playlistId: playlistServer2UUID, attributes: { videoId } }) + } + + await waitJobs(servers) + + command = servers[0].search + }) + + it('Should not find a remote playlist', async function () { + { + const search = servers[1].url + '/video-playlists/43' + const body = await command.searchPlaylists({ search, token: servers[0].accessToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + + { + // Without token + const search = servers[1].url + '/video-playlists/' + playlistServer2UUID + const body = await command.searchPlaylists({ search }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should search a local playlist', async function () { + const search = servers[0].url + '/video-playlists/' + playlistServer1UUID + const body = await command.searchPlaylists({ search }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].displayName).to.equal('playlist 1 on server 1') + expect(body.data[0].videosLength).to.equal(2) + }) + + it('Should search a local playlist with an alternative URL', async function () { + const searches = [ + servers[0].url + '/videos/watch/playlist/' + playlistServer1UUID, + servers[0].url + '/w/p/' + playlistServer1UUID + ] + + for (const search of searches) { + for (const token of [ undefined, servers[0].accessToken ]) { + const body = await command.searchPlaylists({ search, token }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].displayName).to.equal('playlist 1 on server 1') + expect(body.data[0].videosLength).to.equal(2) + } + } + }) + + it('Should search a local playlist with a query in URL', async function () { + const searches = [ + servers[0].url + '/videos/watch/playlist/' + playlistServer1UUID, + servers[0].url + '/w/p/' + playlistServer1UUID + ] + + for (const search of searches) { + for (const token of [ undefined, servers[0].accessToken ]) { + const body = await command.searchPlaylists({ search: search + '?param=1', token }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].displayName).to.equal('playlist 1 on server 1') + expect(body.data[0].videosLength).to.equal(2) + } + } + }) + + it('Should search a remote playlist', async function () { + const searches = [ + servers[1].url + '/video-playlists/' + playlistServer2UUID, + servers[1].url + '/videos/watch/playlist/' + playlistServer2UUID, + servers[1].url + '/w/p/' + playlistServer2UUID + ] + + for (const search of searches) { + const body = await command.searchPlaylists({ search, token: servers[0].accessToken }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].displayName).to.equal('playlist 1 on server 2') + expect(body.data[0].videosLength).to.equal(1) + } + }) + + it('Should not list this remote playlist', async function () { + const body = await servers[0].playlists.list({ start: 0, count: 10 }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].displayName).to.equal('playlist 1 on server 1') + }) + + it('Should update the playlist of server 2, and refresh it on server 1', async function () { + this.timeout(60000) + + await servers[1].playlists.addElement({ playlistId: playlistServer2UUID, attributes: { videoId: video2Server2 } }) + + await waitJobs(servers) + // Expire playlist + await wait(10000) + + // Will run refresh async + const search = servers[1].url + '/video-playlists/' + playlistServer2UUID + await command.searchPlaylists({ search, token: servers[0].accessToken }) + + // Wait refresh + await wait(5000) + + const body = await command.searchPlaylists({ search, token: servers[0].accessToken }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlist = body.data[0] + expect(playlist.videosLength).to.equal(2) + }) + + it('Should delete playlist of server 2, and delete it on server 1', async function () { + this.timeout(60000) + + await servers[1].playlists.delete({ playlistId: playlistServer2UUID }) + + await waitJobs(servers) + // Expiration + await wait(10000) + + // Will run refresh async + const search = servers[1].url + '/video-playlists/' + playlistServer2UUID + await command.searchPlaylists({ search, token: servers[0].accessToken }) + + // Wait refresh + await wait(5000) + + const body = await command.searchPlaylists({ search, token: servers[0].accessToken }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/search/search-activitypub-videos.ts b/packages/tests/src/api/search/search-activitypub-videos.ts new file mode 100644 index 000000000..72759f21e --- /dev/null +++ b/packages/tests/src/api/search/search-activitypub-videos.ts @@ -0,0 +1,196 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + PeerTubeServer, + SearchCommand, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test ActivityPub videos search', function () { + let servers: PeerTubeServer[] + let videoServer1UUID: string + let videoServer2UUID: string + + let command: SearchCommand + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video 1 on server 1' } }) + videoServer1UUID = uuid + } + + { + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'video 1 on server 2' } }) + videoServer2UUID = uuid + } + + await waitJobs(servers) + + command = servers[0].search + }) + + it('Should not find a remote video', async function () { + { + const search = servers[1].url + '/videos/watch/43' + const body = await command.searchVideos({ search, token: servers[0].accessToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + + { + // Without token + const search = servers[1].url + '/videos/watch/' + videoServer2UUID + const body = await command.searchVideos({ search }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should search a local video', async function () { + const search = servers[0].url + '/videos/watch/' + videoServer1UUID + const body = await command.searchVideos({ search }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('video 1 on server 1') + }) + + it('Should search a local video with an alternative URL', async function () { + const search = servers[0].url + '/w/' + videoServer1UUID + const body1 = await command.searchVideos({ search }) + const body2 = await command.searchVideos({ search, token: servers[0].accessToken }) + + for (const body of [ body1, body2 ]) { + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('video 1 on server 1') + } + }) + + it('Should search a local video with a query in URL', async function () { + const searches = [ + servers[0].url + '/w/' + videoServer1UUID, + servers[0].url + '/videos/watch/' + videoServer1UUID + ] + + for (const search of searches) { + for (const token of [ undefined, servers[0].accessToken ]) { + const body = await command.searchVideos({ search: search + '?startTime=4', token }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('video 1 on server 1') + } + } + }) + + it('Should search a remote video', async function () { + const searches = [ + servers[1].url + '/w/' + videoServer2UUID, + servers[1].url + '/videos/watch/' + videoServer2UUID + ] + + for (const search of searches) { + const body = await command.searchVideos({ search, token: servers[0].accessToken }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('video 1 on server 2') + } + }) + + it('Should not list this remote video', async function () { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('video 1 on server 1') + }) + + it('Should update video of server 2, and refresh it on server 1', async function () { + this.timeout(120000) + + const channelAttributes = { + name: 'super_channel', + displayName: 'super channel' + } + const created = await servers[1].channels.create({ attributes: channelAttributes }) + const videoChannelId = created.id + + const attributes = { + name: 'updated', + tag: [ 'tag1', 'tag2' ], + privacy: VideoPrivacy.UNLISTED, + channelId: videoChannelId + } + await servers[1].videos.update({ id: videoServer2UUID, attributes }) + + await waitJobs(servers) + // Expire video + await wait(10000) + + // Will run refresh async + const search = servers[1].url + '/videos/watch/' + videoServer2UUID + await command.searchVideos({ search, token: servers[0].accessToken }) + + // Wait refresh + await wait(5000) + + const body = await command.searchVideos({ search, token: servers[0].accessToken }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const video = body.data[0] + expect(video.name).to.equal('updated') + expect(video.channel.name).to.equal('super_channel') + expect(video.privacy.id).to.equal(VideoPrivacy.UNLISTED) + }) + + it('Should delete video of server 2, and delete it on server 1', async function () { + this.timeout(120000) + + await servers[1].videos.remove({ id: videoServer2UUID }) + + await waitJobs(servers) + // Expire video + await wait(10000) + + // Will run refresh async + const search = servers[1].url + '/videos/watch/' + videoServer2UUID + await command.searchVideos({ search, token: servers[0].accessToken }) + + // Wait refresh + await wait(5000) + + const body = await command.searchVideos({ search, token: servers[0].accessToken }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/search/search-channels.ts b/packages/tests/src/api/search/search-channels.ts new file mode 100644 index 000000000..36596e036 --- /dev/null +++ b/packages/tests/src/api/search/search-channels.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { VideoChannel } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + doubleFollow, + PeerTubeServer, + SearchCommand, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar +} from '@peertube/peertube-server-commands' + +describe('Test channels search', function () { + let server: PeerTubeServer + let remoteServer: PeerTubeServer + let command: SearchCommand + + before(async function () { + this.timeout(120000) + + const servers = await Promise.all([ + createSingleServer(1), + createSingleServer(2) + ]) + server = servers[0] + remoteServer = servers[1] + + await setAccessTokensToServers([ server, remoteServer ]) + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(server) + + await servers[1].config.disableTranscoding() + + { + await server.users.create({ username: 'user1' }) + const channel = { + name: 'squall_channel', + displayName: 'Squall channel' + } + await server.channels.create({ attributes: channel }) + } + + { + await remoteServer.users.create({ username: 'user1' }) + const channel = { + name: 'zell_channel', + displayName: 'Zell channel' + } + const { id } = await remoteServer.channels.create({ attributes: channel }) + + await remoteServer.videos.upload({ attributes: { channelId: id } }) + } + + await doubleFollow(server, remoteServer) + + command = server.search + }) + + it('Should make a simple search and not have results', async function () { + const body = await command.searchChannels({ search: 'abc' }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should make a search and have results', async function () { + { + const search = { + search: 'Squall', + start: 0, + count: 1 + } + const body = await command.advancedChannelSearch({ search }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const channel: VideoChannel = body.data[0] + expect(channel.name).to.equal('squall_channel') + expect(channel.displayName).to.equal('Squall channel') + } + + { + const search = { + search: 'Squall', + start: 1, + count: 1 + } + + const body = await command.advancedChannelSearch({ search }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should filter by host', async function () { + { + const search = { search: 'channel', host: remoteServer.host } + + const body = await command.advancedChannelSearch({ search }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].displayName).to.equal('Zell channel') + } + + { + const search = { search: 'Sq', host: server.host } + + const body = await command.advancedChannelSearch({ search }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].displayName).to.equal('Squall channel') + } + + { + const search = { search: 'Squall', host: 'example.com' } + + const body = await command.advancedChannelSearch({ search }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should filter by names', async function () { + { + const body = await command.advancedChannelSearch({ search: { handles: [ 'squall_channel', 'zell_channel' ] } }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].displayName).to.equal('Squall channel') + } + + { + const body = await command.advancedChannelSearch({ search: { handles: [ 'squall_channel@' + server.host ] } }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].displayName).to.equal('Squall channel') + } + + { + const body = await command.advancedChannelSearch({ search: { handles: [ 'chocobozzz_channel' ] } }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + + { + const body = await command.advancedChannelSearch({ search: { handles: [ 'squall_channel', 'zell_channel@' + remoteServer.host ] } }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].displayName).to.equal('Squall channel') + expect(body.data[1].displayName).to.equal('Zell channel') + } + }) + + after(async function () { + await cleanupTests([ server, remoteServer ]) + }) +}) diff --git a/packages/tests/src/api/search/search-index.ts b/packages/tests/src/api/search/search-index.ts new file mode 100644 index 000000000..4bac7ea94 --- /dev/null +++ b/packages/tests/src/api/search/search-index.ts @@ -0,0 +1,438 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + BooleanBothQuery, + VideoChannelsSearchQuery, + VideoPlaylistPrivacy, + VideoPlaylistsSearchQuery, + VideoPlaylistType, + VideosSearchQuery +} from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + SearchCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test index search', function () { + const localVideoName = 'local video' + new Date().toISOString() + + let server: PeerTubeServer = null + let command: SearchCommand + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + await server.videos.upload({ attributes: { name: localVideoName } }) + + command = server.search + }) + + describe('Default search', async function () { + + it('Should make a local videos search by default', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + search: { + searchIndex: { + enabled: true, + isDefaultSearch: false, + disableLocalSearch: false + } + } + } + }) + + const body = await command.searchVideos({ search: 'local video' }) + + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal(localVideoName) + }) + + it('Should make a local channels search by default', async function () { + const body = await command.searchChannels({ search: 'root' }) + + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('root_channel') + expect(body.data[0].host).to.equal(server.host) + }) + + it('Should make an index videos search by default', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + search: { + searchIndex: { + enabled: true, + isDefaultSearch: true, + disableLocalSearch: false + } + } + } + }) + + const body = await command.searchVideos({ search: 'local video' }) + expect(body.total).to.be.greaterThan(2) + }) + + it('Should make an index channels search by default', async function () { + const body = await command.searchChannels({ search: 'root' }) + expect(body.total).to.be.greaterThan(2) + }) + }) + + describe('Videos search', async function () { + + async function check (search: VideosSearchQuery, exists = true) { + const body = await command.advancedVideoSearch({ search }) + + if (exists === false) { + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + return + } + + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const video = body.data[0] + + expect(video.name).to.equal('What is PeerTube?') + expect(video.category.label).to.equal('Science & Technology') + expect(video.licence.label).to.equal('Attribution - Share Alike') + expect(video.privacy.label).to.equal('Public') + expect(video.duration).to.equal(113) + expect(video.thumbnailUrl.startsWith('https://framatube.org/static/thumbnails')).to.be.true + + expect(video.account.host).to.equal('framatube.org') + expect(video.account.name).to.equal('framasoft') + expect(video.account.url).to.equal('https://framatube.org/accounts/framasoft') + expect(video.account.avatars.length).to.equal(2, 'Account should have one avatar image') + + expect(video.channel.host).to.equal('framatube.org') + expect(video.channel.name).to.equal('joinpeertube') + expect(video.channel.url).to.equal('https://framatube.org/video-channels/joinpeertube') + expect(video.channel.avatars.length).to.equal(2, 'Channel should have one avatar image') + } + + const baseSearch: VideosSearchQuery = { + search: 'what is peertube', + start: 0, + count: 2, + categoryOneOf: [ 15 ], + licenceOneOf: [ 2 ], + tagsAllOf: [ 'framasoft', 'peertube' ], + startDate: '2018-10-01T10:50:46.396Z', + endDate: '2018-10-01T10:55:46.396Z' + } + + it('Should make a simple search and not have results', async function () { + const body = await command.searchVideos({ search: 'djidane'.repeat(50) }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should make a simple search and have results', async function () { + const body = await command.searchVideos({ search: 'What is PeerTube' }) + + expect(body.total).to.be.greaterThan(1) + }) + + it('Should make a simple search', async function () { + await check(baseSearch) + }) + + it('Should search by start date', async function () { + const search = { ...baseSearch, startDate: '2018-10-01T10:54:46.396Z' } + await check(search, false) + }) + + it('Should search by tags', async function () { + const search = { ...baseSearch, tagsAllOf: [ 'toto', 'framasoft' ] } + await check(search, false) + }) + + it('Should search by duration', async function () { + const search = { ...baseSearch, durationMin: 2000 } + await check(search, false) + }) + + it('Should search by nsfw attribute', async function () { + { + const search = { ...baseSearch, nsfw: 'true' as BooleanBothQuery } + await check(search, false) + } + + { + const search = { ...baseSearch, nsfw: 'false' as BooleanBothQuery } + await check(search, true) + } + + { + const search = { ...baseSearch, nsfw: 'both' as BooleanBothQuery } + await check(search, true) + } + }) + + it('Should search by host', async function () { + { + const search = { ...baseSearch, host: 'example.com' } + await check(search, false) + } + + { + const search = { ...baseSearch, host: 'framatube.org' } + await check(search, true) + } + }) + + it('Should search by uuids', async function () { + const goodUUID = '9c9de5e8-0a1e-484a-b099-e80766180a6d' + const goodShortUUID = 'kkGMgK9ZtnKfYAgnEtQxbv' + const badUUID = 'c29c5b77-4a04-493d-96a9-2e9267e308f0' + const badShortUUID = 'rP5RgUeX9XwTSrspCdkDej' + + { + const uuidsMatrix = [ + [ goodUUID ], + [ goodUUID, badShortUUID ], + [ badShortUUID, goodShortUUID ], + [ goodUUID, goodShortUUID ] + ] + + for (const uuids of uuidsMatrix) { + const search = { ...baseSearch, uuids } + await check(search, true) + } + } + + { + const uuidsMatrix = [ + [ badUUID ], + [ badShortUUID ] + ] + + for (const uuids of uuidsMatrix) { + const search = { ...baseSearch, uuids } + await check(search, false) + } + } + }) + + it('Should have a correct pagination', async function () { + const search = { + search: 'video', + start: 0, + count: 5 + } + + const body = await command.advancedVideoSearch({ search }) + + expect(body.total).to.be.greaterThan(5) + expect(body.data).to.have.lengthOf(5) + }) + + it('Should use the nsfw instance policy as default', async function () { + let nsfwUUID: string + + { + await server.config.updateCustomSubConfig({ + newConfig: { + instance: { defaultNSFWPolicy: 'display' } + } + }) + + const body = await command.searchVideos({ search: 'NSFW search index', sort: '-match' }) + expect(body.data).to.have.length.greaterThan(0) + + const video = body.data[0] + expect(video.nsfw).to.be.true + + nsfwUUID = video.uuid + } + + { + await server.config.updateCustomSubConfig({ + newConfig: { + instance: { defaultNSFWPolicy: 'do_not_list' } + } + }) + + const body = await command.searchVideos({ search: 'NSFW search index', sort: '-match' }) + + try { + expect(body.data).to.have.lengthOf(0) + } catch { + const video = body.data[0] + + expect(video.uuid).not.equal(nsfwUUID) + } + } + }) + }) + + describe('Channels search', async function () { + + async function check (search: VideoChannelsSearchQuery, exists = true) { + const body = await command.advancedChannelSearch({ search }) + + if (exists === false) { + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + return + } + + expect(body.total).to.be.greaterThan(0) + expect(body.data).to.have.length.greaterThan(0) + + const videoChannel = body.data[0] + expect(videoChannel.url).to.equal('https://framatube.org/video-channels/bf54d359-cfad-4935-9d45-9d6be93f63e8') + expect(videoChannel.host).to.equal('framatube.org') + expect(videoChannel.avatars.length).to.equal(2, 'Channel should have two avatar images') + expect(videoChannel.displayName).to.exist + + expect(videoChannel.ownerAccount.url).to.equal('https://framatube.org/accounts/framasoft') + expect(videoChannel.ownerAccount.name).to.equal('framasoft') + expect(videoChannel.ownerAccount.host).to.equal('framatube.org') + expect(videoChannel.ownerAccount.avatars.length).to.equal(2, 'Account should have two avatar images') + } + + it('Should make a simple search and not have results', async function () { + const body = await command.searchChannels({ search: 'a'.repeat(500) }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should make a search and have results', async function () { + await check({ search: 'Framasoft', sort: 'createdAt' }, true) + }) + + it('Should make host search and have appropriate results', async function () { + await check({ search: 'Framasoft videos', host: 'example.com' }, false) + await check({ search: 'Framasoft videos', host: 'framatube.org' }, true) + }) + + it('Should make handles search and have appropriate results', async function () { + await check({ handles: [ 'bf54d359-cfad-4935-9d45-9d6be93f63e8@framatube.org' ] }, true) + await check({ handles: [ 'jeanine', 'bf54d359-cfad-4935-9d45-9d6be93f63e8@framatube.org' ] }, true) + await check({ handles: [ 'jeanine', 'chocobozzz_channel2@peertube2.cpy.re' ] }, false) + }) + + it('Should have a correct pagination', async function () { + const body = await command.advancedChannelSearch({ search: { search: 'root', start: 0, count: 2 } }) + + expect(body.total).to.be.greaterThan(2) + expect(body.data).to.have.lengthOf(2) + }) + }) + + describe('Playlists search', async function () { + + async function check (search: VideoPlaylistsSearchQuery, exists = true) { + const body = await command.advancedPlaylistSearch({ search }) + + if (exists === false) { + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + return + } + + expect(body.total).to.be.greaterThan(0) + expect(body.data).to.have.length.greaterThan(0) + + const videoPlaylist = body.data[0] + + expect(videoPlaylist.url).to.equal('https://peertube2.cpy.re/videos/watch/playlist/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a') + expect(videoPlaylist.thumbnailUrl).to.exist + expect(videoPlaylist.embedUrl).to.equal('https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a') + + expect(videoPlaylist.type.id).to.equal(VideoPlaylistType.REGULAR) + expect(videoPlaylist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC) + expect(videoPlaylist.videosLength).to.exist + + expect(videoPlaylist.createdAt).to.exist + expect(videoPlaylist.updatedAt).to.exist + + expect(videoPlaylist.uuid).to.equal('73804a40-da9a-40c2-b1eb-2c6d9eec8f0a') + expect(videoPlaylist.displayName).to.exist + + expect(videoPlaylist.ownerAccount.url).to.equal('https://peertube2.cpy.re/accounts/chocobozzz') + expect(videoPlaylist.ownerAccount.name).to.equal('chocobozzz') + expect(videoPlaylist.ownerAccount.host).to.equal('peertube2.cpy.re') + expect(videoPlaylist.ownerAccount.avatars.length).to.equal(2, 'Account should have two avatar images') + + expect(videoPlaylist.videoChannel.url).to.equal('https://peertube2.cpy.re/video-channels/chocobozzz_channel') + expect(videoPlaylist.videoChannel.name).to.equal('chocobozzz_channel') + expect(videoPlaylist.videoChannel.host).to.equal('peertube2.cpy.re') + expect(videoPlaylist.videoChannel.avatars.length).to.equal(2, 'Channel should have two avatar images') + } + + it('Should make a simple search and not have results', async function () { + const body = await command.searchPlaylists({ search: 'a'.repeat(500) }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should make a search and have results', async function () { + await check({ search: 'E2E playlist', sort: '-match' }, true) + }) + + it('Should make host search and have appropriate results', async function () { + await check({ search: 'E2E playlist', host: 'example.com' }, false) + await check({ search: 'E2E playlist', host: 'peertube2.cpy.re', sort: '-match' }, true) + }) + + it('Should make a search by uuids and have appropriate results', async function () { + const goodUUID = '73804a40-da9a-40c2-b1eb-2c6d9eec8f0a' + const goodShortUUID = 'fgei1ws1oa6FCaJ2qZPG29' + const badUUID = 'c29c5b77-4a04-493d-96a9-2e9267e308f0' + const badShortUUID = 'rP5RgUeX9XwTSrspCdkDej' + + { + const uuidsMatrix = [ + [ goodUUID ], + [ goodUUID, badShortUUID ], + [ badShortUUID, goodShortUUID ], + [ goodUUID, goodShortUUID ] + ] + + for (const uuids of uuidsMatrix) { + const search = { search: 'E2E playlist', sort: '-match', uuids } + await check(search, true) + } + } + + { + const uuidsMatrix = [ + [ badUUID ], + [ badShortUUID ] + ] + + for (const uuids of uuidsMatrix) { + const search = { search: 'E2E playlist', sort: '-match', uuids } + await check(search, false) + } + } + }) + + it('Should have a correct pagination', async function () { + const body = await command.advancedChannelSearch({ search: { search: 'root', start: 0, count: 2 } }) + + expect(body.total).to.be.greaterThan(2) + expect(body.data).to.have.lengthOf(2) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/search/search-playlists.ts b/packages/tests/src/api/search/search-playlists.ts new file mode 100644 index 000000000..cd16e202e --- /dev/null +++ b/packages/tests/src/api/search/search-playlists.ts @@ -0,0 +1,180 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { VideoPlaylistPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + doubleFollow, + PeerTubeServer, + SearchCommand, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test playlists search', function () { + let server: PeerTubeServer + let remoteServer: PeerTubeServer + let command: SearchCommand + let playlistUUID: string + let playlistShortUUID: string + + before(async function () { + this.timeout(120000) + + const servers = await Promise.all([ + createSingleServer(1), + createSingleServer(2) + ]) + server = servers[0] + remoteServer = servers[1] + + await setAccessTokensToServers([ remoteServer, server ]) + await setDefaultVideoChannel([ remoteServer, server ]) + await setDefaultChannelAvatar([ remoteServer, server ]) + await setDefaultAccountAvatar([ remoteServer, server ]) + + await servers[1].config.disableTranscoding() + + { + const videoId = (await server.videos.upload()).uuid + + const attributes = { + displayName: 'Dr. Kenzo Tenma hospital videos', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: server.store.channel.id + } + const created = await server.playlists.create({ attributes }) + playlistUUID = created.uuid + playlistShortUUID = created.shortUUID + + await server.playlists.addElement({ playlistId: created.id, attributes: { videoId } }) + } + + { + const videoId = (await remoteServer.videos.upload()).uuid + + const attributes = { + displayName: 'Johan & Anna Libert music videos', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: remoteServer.store.channel.id + } + const created = await remoteServer.playlists.create({ attributes }) + + await remoteServer.playlists.addElement({ playlistId: created.id, attributes: { videoId } }) + } + + { + const attributes = { + displayName: 'Inspector Lunge playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: server.store.channel.id + } + await server.playlists.create({ attributes }) + } + + await doubleFollow(server, remoteServer) + + command = server.search + }) + + it('Should make a simple search and not have results', async function () { + const body = await command.searchPlaylists({ search: 'abc' }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should make a search and have results', async function () { + { + const search = { + search: 'tenma', + start: 0, + count: 1 + } + const body = await command.advancedPlaylistSearch({ search }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlist = body.data[0] + expect(playlist.displayName).to.equal('Dr. Kenzo Tenma hospital videos') + expect(playlist.url).to.equal(server.url + '/video-playlists/' + playlist.uuid) + } + + { + const search = { + search: 'Anna Livert music', + start: 0, + count: 1 + } + const body = await command.advancedPlaylistSearch({ search }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlist = body.data[0] + expect(playlist.displayName).to.equal('Johan & Anna Libert music videos') + } + }) + + it('Should filter by host', async function () { + { + const search = { search: 'tenma', host: server.host } + const body = await command.advancedPlaylistSearch({ search }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlist = body.data[0] + expect(playlist.displayName).to.equal('Dr. Kenzo Tenma hospital videos') + } + + { + const search = { search: 'Anna', host: 'example.com' } + const body = await command.advancedPlaylistSearch({ search }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + + { + const search = { search: 'video', host: remoteServer.host } + const body = await command.advancedPlaylistSearch({ search }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlist = body.data[0] + expect(playlist.displayName).to.equal('Johan & Anna Libert music videos') + } + }) + + it('Should filter by UUIDs', async function () { + for (const uuid of [ playlistUUID, playlistShortUUID ]) { + const body = await command.advancedPlaylistSearch({ search: { uuids: [ uuid ] } }) + + expect(body.total).to.equal(1) + expect(body.data[0].displayName).to.equal('Dr. Kenzo Tenma hospital videos') + } + + { + const body = await command.advancedPlaylistSearch({ search: { uuids: [ 'dfd70b83-639f-4980-94af-304a56ab4b35' ] } }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should not display playlists without videos', async function () { + const search = { + search: 'Lunge', + start: 0, + count: 1 + } + const body = await command.advancedPlaylistSearch({ search }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + after(async function () { + await cleanupTests([ server, remoteServer ]) + }) +}) diff --git a/packages/tests/src/api/search/search-videos.ts b/packages/tests/src/api/search/search-videos.ts new file mode 100644 index 000000000..0739f0886 --- /dev/null +++ b/packages/tests/src/api/search/search-videos.ts @@ -0,0 +1,568 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + doubleFollow, + PeerTubeServer, + SearchCommand, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + setDefaultVideoChannel, + stopFfmpeg +} from '@peertube/peertube-server-commands' + +describe('Test videos search', function () { + let server: PeerTubeServer + let remoteServer: PeerTubeServer + let startDate: string + let videoUUID: string + let videoShortUUID: string + + let command: SearchCommand + + before(async function () { + this.timeout(360000) + + const servers = await Promise.all([ + createSingleServer(1), + createSingleServer(2) + ]) + server = servers[0] + remoteServer = servers[1] + + await setAccessTokensToServers([ server, remoteServer ]) + await setDefaultVideoChannel([ server, remoteServer ]) + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(servers) + + { + const attributes1 = { + name: '1111 2222 3333', + fixture: '60fps_720p_small.mp4', // 2 seconds + category: 1, + licence: 1, + nsfw: false, + language: 'fr' + } + await server.videos.upload({ attributes: attributes1 }) + + const attributes2 = { ...attributes1, name: attributes1.name + ' - 2', fixture: 'video_short.mp4' } + await server.videos.upload({ attributes: attributes2 }) + + { + const attributes3 = { ...attributes1, name: attributes1.name + ' - 3', language: undefined } + const { id, uuid, shortUUID } = await server.videos.upload({ attributes: attributes3 }) + videoUUID = uuid + videoShortUUID = shortUUID + + await server.captions.add({ + language: 'en', + videoId: id, + fixture: 'subtitle-good2.vtt', + mimeType: 'application/octet-stream' + }) + + await server.captions.add({ + language: 'aa', + videoId: id, + fixture: 'subtitle-good2.vtt', + mimeType: 'application/octet-stream' + }) + } + + const attributes4 = { ...attributes1, name: attributes1.name + ' - 4', language: 'pl', nsfw: true } + await server.videos.upload({ attributes: attributes4 }) + + await wait(1000) + + startDate = new Date().toISOString() + + const attributes5 = { ...attributes1, name: attributes1.name + ' - 5', licence: 2, language: undefined } + await server.videos.upload({ attributes: attributes5 }) + + const attributes6 = { ...attributes1, name: attributes1.name + ' - 6', tags: [ 't1', 't2' ] } + await server.videos.upload({ attributes: attributes6 }) + + const attributes7 = { ...attributes1, name: attributes1.name + ' - 7', originallyPublishedAt: '2019-02-12T09:58:08.286Z' } + await server.videos.upload({ attributes: attributes7 }) + + const attributes8 = { ...attributes1, name: attributes1.name + ' - 8', licence: 4 } + await server.videos.upload({ attributes: attributes8 }) + } + + { + const attributes = { + name: '3333 4444 5555', + fixture: 'video_short.mp4', + category: 2, + licence: 2, + language: 'en' + } + await server.videos.upload({ attributes }) + + await server.videos.upload({ attributes: { ...attributes, name: attributes.name + ' duplicate' } }) + } + + { + const attributes = { + name: '6666 7777 8888', + fixture: 'video_short.mp4', + category: 3, + licence: 3, + language: 'pl' + } + await server.videos.upload({ attributes }) + } + + { + const attributes1 = { + name: '9999', + tags: [ 'aaaa', 'bbbb', 'cccc' ], + category: 1 + } + await server.videos.upload({ attributes: attributes1 }) + await server.videos.upload({ attributes: { ...attributes1, category: 2 } }) + + await server.videos.upload({ attributes: { ...attributes1, tags: [ 'cccc', 'dddd' ] } }) + await server.videos.upload({ attributes: { ...attributes1, tags: [ 'eeee', 'ffff' ] } }) + } + + { + const attributes1 = { + name: 'aaaa 2', + category: 1 + } + await server.videos.upload({ attributes: attributes1 }) + await server.videos.upload({ attributes: { ...attributes1, category: 2 } }) + } + + { + await remoteServer.videos.upload({ attributes: { name: 'remote video 1' } }) + await remoteServer.videos.upload({ attributes: { name: 'remote video 2' } }) + } + + await doubleFollow(server, remoteServer) + + command = server.search + }) + + it('Should make a simple search and not have results', async function () { + const body = await command.searchVideos({ search: 'abc' }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should make a simple search and have results', async function () { + const body = await command.searchVideos({ search: '4444 5555 duplicate' }) + + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + + // bestmatch + expect(videos[0].name).to.equal('3333 4444 5555 duplicate') + expect(videos[1].name).to.equal('3333 4444 5555') + }) + + it('Should make a search on tags too, and have results', async function () { + const search = { + search: 'aaaa', + categoryOneOf: [ 1 ] + } + const body = await command.advancedVideoSearch({ search }) + + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + + // bestmatch + expect(videos[0].name).to.equal('aaaa 2') + expect(videos[1].name).to.equal('9999') + }) + + it('Should filter on tags without a search', async function () { + const search = { + tagsAllOf: [ 'bbbb' ] + } + const body = await command.advancedVideoSearch({ search }) + + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + + expect(videos[0].name).to.equal('9999') + expect(videos[1].name).to.equal('9999') + }) + + it('Should filter on category without a search', async function () { + const search = { + categoryOneOf: [ 3 ] + } + const body = await command.advancedVideoSearch({ search }) + + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + + expect(videos[0].name).to.equal('6666 7777 8888') + }) + + it('Should search by tags (one of)', async function () { + const query = { + search: '9999', + categoryOneOf: [ 1 ], + tagsOneOf: [ 'aAaa', 'ffff' ] + } + + { + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(2) + } + + { + const body = await command.advancedVideoSearch({ search: { ...query, tagsOneOf: [ 'blabla' ] } }) + expect(body.total).to.equal(0) + } + }) + + it('Should search by tags (all of)', async function () { + const query = { + search: '9999', + categoryOneOf: [ 1 ], + tagsAllOf: [ 'CCcc' ] + } + + { + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(2) + } + + { + const body = await command.advancedVideoSearch({ search: { ...query, tagsAllOf: [ 'blAbla' ] } }) + expect(body.total).to.equal(0) + } + + { + const body = await command.advancedVideoSearch({ search: { ...query, tagsAllOf: [ 'bbbb', 'CCCC' ] } }) + expect(body.total).to.equal(1) + } + }) + + it('Should search by category', async function () { + const query = { + search: '6666', + categoryOneOf: [ 3 ] + } + + { + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('6666 7777 8888') + } + + { + const body = await command.advancedVideoSearch({ search: { ...query, categoryOneOf: [ 2 ] } }) + expect(body.total).to.equal(0) + } + }) + + it('Should search by licence', async function () { + const query = { + search: '4444 5555', + licenceOneOf: [ 2 ] + } + + { + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(2) + expect(body.data[0].name).to.equal('3333 4444 5555') + expect(body.data[1].name).to.equal('3333 4444 5555 duplicate') + } + + { + const body = await command.advancedVideoSearch({ search: { ...query, licenceOneOf: [ 3 ] } }) + expect(body.total).to.equal(0) + } + }) + + it('Should search by languages', async function () { + const query = { + search: '1111 2222 3333', + languageOneOf: [ 'pl', 'en' ] + } + + { + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(2) + expect(body.data[0].name).to.equal('1111 2222 3333 - 3') + expect(body.data[1].name).to.equal('1111 2222 3333 - 4') + } + + { + const body = await command.advancedVideoSearch({ search: { ...query, languageOneOf: [ 'pl', 'en', '_unknown' ] } }) + expect(body.total).to.equal(3) + expect(body.data[0].name).to.equal('1111 2222 3333 - 3') + expect(body.data[1].name).to.equal('1111 2222 3333 - 4') + expect(body.data[2].name).to.equal('1111 2222 3333 - 5') + } + + { + const body = await command.advancedVideoSearch({ search: { ...query, languageOneOf: [ 'eo' ] } }) + expect(body.total).to.equal(0) + } + }) + + it('Should search by start date', async function () { + const query = { + search: '1111 2222 3333', + startDate + } + + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(4) + + const videos = body.data + expect(videos[0].name).to.equal('1111 2222 3333 - 5') + expect(videos[1].name).to.equal('1111 2222 3333 - 6') + expect(videos[2].name).to.equal('1111 2222 3333 - 7') + expect(videos[3].name).to.equal('1111 2222 3333 - 8') + }) + + it('Should make an advanced search', async function () { + const query = { + search: '1111 2222 3333', + languageOneOf: [ 'pl', 'fr' ], + durationMax: 4, + nsfw: 'false' as 'false', + licenceOneOf: [ 1, 4 ] + } + + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(4) + + const videos = body.data + expect(videos[0].name).to.equal('1111 2222 3333') + expect(videos[1].name).to.equal('1111 2222 3333 - 6') + expect(videos[2].name).to.equal('1111 2222 3333 - 7') + expect(videos[3].name).to.equal('1111 2222 3333 - 8') + }) + + it('Should make an advanced search and sort results', async function () { + const query = { + search: '1111 2222 3333', + languageOneOf: [ 'pl', 'fr' ], + durationMax: 4, + nsfw: 'false' as 'false', + licenceOneOf: [ 1, 4 ], + sort: '-name' + } + + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(4) + + const videos = body.data + expect(videos[0].name).to.equal('1111 2222 3333 - 8') + expect(videos[1].name).to.equal('1111 2222 3333 - 7') + expect(videos[2].name).to.equal('1111 2222 3333 - 6') + expect(videos[3].name).to.equal('1111 2222 3333') + }) + + it('Should make an advanced search and only show the first result', async function () { + const query = { + search: '1111 2222 3333', + languageOneOf: [ 'pl', 'fr' ], + durationMax: 4, + nsfw: 'false' as 'false', + licenceOneOf: [ 1, 4 ], + sort: '-name', + start: 0, + count: 1 + } + + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(4) + + const videos = body.data + expect(videos[0].name).to.equal('1111 2222 3333 - 8') + }) + + it('Should make an advanced search and only show the last result', async function () { + const query = { + search: '1111 2222 3333', + languageOneOf: [ 'pl', 'fr' ], + durationMax: 4, + nsfw: 'false' as 'false', + licenceOneOf: [ 1, 4 ], + sort: '-name', + start: 3, + count: 1 + } + + const body = await command.advancedVideoSearch({ search: query }) + expect(body.total).to.equal(4) + + const videos = body.data + expect(videos[0].name).to.equal('1111 2222 3333') + }) + + it('Should search on originally published date', async function () { + const baseQuery = { + search: '1111 2222 3333', + languageOneOf: [ 'pl', 'fr' ], + durationMax: 4, + nsfw: 'false' as 'false', + licenceOneOf: [ 1, 4 ] + } + + { + const query = { ...baseQuery, originallyPublishedStartDate: '2019-02-11T09:58:08.286Z' } + const body = await command.advancedVideoSearch({ search: query }) + + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('1111 2222 3333 - 7') + } + + { + const query = { ...baseQuery, originallyPublishedEndDate: '2019-03-11T09:58:08.286Z' } + const body = await command.advancedVideoSearch({ search: query }) + + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('1111 2222 3333 - 7') + } + + { + const query = { ...baseQuery, originallyPublishedEndDate: '2019-01-11T09:58:08.286Z' } + const body = await command.advancedVideoSearch({ search: query }) + + expect(body.total).to.equal(0) + } + + { + const query = { ...baseQuery, originallyPublishedStartDate: '2019-03-11T09:58:08.286Z' } + const body = await command.advancedVideoSearch({ search: query }) + + expect(body.total).to.equal(0) + } + + { + const query = { + ...baseQuery, + originallyPublishedStartDate: '2019-01-11T09:58:08.286Z', + originallyPublishedEndDate: '2019-01-10T09:58:08.286Z' + } + const body = await command.advancedVideoSearch({ search: query }) + + expect(body.total).to.equal(0) + } + + { + const query = { + ...baseQuery, + originallyPublishedStartDate: '2019-01-11T09:58:08.286Z', + originallyPublishedEndDate: '2019-04-11T09:58:08.286Z' + } + const body = await command.advancedVideoSearch({ search: query }) + + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('1111 2222 3333 - 7') + } + }) + + it('Should search by UUID', async function () { + const search = videoUUID + const body = await command.advancedVideoSearch({ search: { search } }) + + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('1111 2222 3333 - 3') + }) + + it('Should filter by UUIDs', async function () { + for (const uuid of [ videoUUID, videoShortUUID ]) { + const body = await command.advancedVideoSearch({ search: { uuids: [ uuid ] } }) + + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('1111 2222 3333 - 3') + } + + { + const body = await command.advancedVideoSearch({ search: { uuids: [ 'dfd70b83-639f-4980-94af-304a56ab4b35' ] } }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should search by host', async function () { + { + const body = await command.advancedVideoSearch({ search: { search: '6666 7777 8888', host: server.host } }) + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('6666 7777 8888') + } + + { + const body = await command.advancedVideoSearch({ search: { search: '1111', host: 'example.com' } }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + + { + const body = await command.advancedVideoSearch({ search: { search: 'remote', host: remoteServer.host } }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].name).to.equal('remote video 1') + expect(body.data[1].name).to.equal('remote video 2') + } + }) + + it('Should search by live', async function () { + this.timeout(120000) + + { + const newConfig = { + search: { + searchIndex: { enabled: false } + }, + live: { enabled: true } + } + await server.config.updateCustomSubConfig({ newConfig }) + } + + { + const body = await command.advancedVideoSearch({ search: { isLive: true } }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + + { + const liveCommand = server.live + + const liveAttributes = { name: 'live', privacy: VideoPrivacy.PUBLIC, channelId: server.store.channel.id } + const live = await liveCommand.create({ fields: liveAttributes }) + + const ffmpegCommand = await liveCommand.sendRTMPStreamInVideo({ videoId: live.id }) + await liveCommand.waitUntilPublished({ videoId: live.id }) + + const body = await command.advancedVideoSearch({ search: { isLive: true } }) + + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('live') + + await stopFfmpeg(ffmpegCommand) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/auto-follows.ts b/packages/tests/src/api/server/auto-follows.ts new file mode 100644 index 000000000..aa272ebcc --- /dev/null +++ b/packages/tests/src/api/server/auto-follows.ts @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { MockInstancesIndex } from '@tests/shared/mock-servers/index.js' +import { wait } from '@peertube/peertube-core-utils' +import { cleanupTests, createMultipleServers, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands' + +async function checkFollow (follower: PeerTubeServer, following: PeerTubeServer, exists: boolean) { + { + const body = await following.follows.getFollowers({ start: 0, count: 5, sort: '-createdAt' }) + const follow = body.data.find(f => f.follower.host === follower.host && f.state === 'accepted') + + if (exists === true) expect(follow, `Follower ${follower.url} should exist on ${following.url}`).to.exist + else expect(follow, `Follower ${follower.url} should not exist on ${following.url}`).to.be.undefined + } + + { + const body = await follower.follows.getFollowings({ start: 0, count: 5, sort: '-createdAt' }) + const follow = body.data.find(f => f.following.host === following.host && f.state === 'accepted') + + if (exists === true) expect(follow, `Following ${following.url} should exist on ${follower.url}`).to.exist + else expect(follow, `Following ${following.url} should not exist on ${follower.url}`).to.be.undefined + } +} + +async function server1Follows2 (servers: PeerTubeServer[]) { + await servers[0].follows.follow({ hosts: [ servers[1].host ] }) + + await waitJobs(servers) +} + +async function resetFollows (servers: PeerTubeServer[]) { + try { + await servers[0].follows.unfollow({ target: servers[1] }) + await servers[1].follows.unfollow({ target: servers[0] }) + } catch { /* empty */ + } + + await waitJobs(servers) + + await checkFollow(servers[0], servers[1], false) + await checkFollow(servers[1], servers[0], false) +} + +describe('Test auto follows', function () { + let servers: PeerTubeServer[] = [] + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + }) + + describe('Auto follow back', function () { + + it('Should not auto follow back if the option is not enabled', async function () { + this.timeout(15000) + + await server1Follows2(servers) + + await checkFollow(servers[0], servers[1], true) + await checkFollow(servers[1], servers[0], false) + + await resetFollows(servers) + }) + + it('Should auto follow back on auto accept if the option is enabled', async function () { + this.timeout(15000) + + const config = { + followings: { + instance: { + autoFollowBack: { enabled: true } + } + } + } + await servers[1].config.updateCustomSubConfig({ newConfig: config }) + + await server1Follows2(servers) + + await checkFollow(servers[0], servers[1], true) + await checkFollow(servers[1], servers[0], true) + + await resetFollows(servers) + }) + + it('Should wait the acceptation before auto follow back', async function () { + this.timeout(30000) + + const config = { + followings: { + instance: { + autoFollowBack: { enabled: true } + } + }, + followers: { + instance: { + manualApproval: true + } + } + } + await servers[1].config.updateCustomSubConfig({ newConfig: config }) + + await server1Follows2(servers) + + await checkFollow(servers[0], servers[1], false) + await checkFollow(servers[1], servers[0], false) + + await servers[1].follows.acceptFollower({ follower: 'peertube@' + servers[0].host }) + await waitJobs(servers) + + await checkFollow(servers[0], servers[1], true) + await checkFollow(servers[1], servers[0], true) + + await resetFollows(servers) + + config.followings.instance.autoFollowBack.enabled = false + config.followers.instance.manualApproval = false + await servers[1].config.updateCustomSubConfig({ newConfig: config }) + }) + }) + + describe('Auto follow index', function () { + const instanceIndexServer = new MockInstancesIndex() + let port: number + + before(async function () { + port = await instanceIndexServer.initialize() + }) + + it('Should not auto follow index if the option is not enabled', async function () { + this.timeout(30000) + + await wait(5000) + await waitJobs(servers) + + await checkFollow(servers[0], servers[1], false) + await checkFollow(servers[1], servers[0], false) + }) + + it('Should auto follow the index', async function () { + this.timeout(30000) + + instanceIndexServer.addInstance(servers[1].host) + + const config = { + followings: { + instance: { + autoFollowIndex: { + indexUrl: `http://127.0.0.1:${port}/api/v1/instances/hosts`, + enabled: true + } + } + } + } + await servers[0].config.updateCustomSubConfig({ newConfig: config }) + + await wait(5000) + await waitJobs(servers) + + await checkFollow(servers[0], servers[1], true) + + await resetFollows(servers) + }) + + it('Should follow new added instances in the index but not old ones', async function () { + this.timeout(30000) + + instanceIndexServer.addInstance(servers[2].host) + + await wait(5000) + await waitJobs(servers) + + await checkFollow(servers[0], servers[1], false) + await checkFollow(servers[0], servers[2], true) + }) + + after(async function () { + await instanceIndexServer.terminate() + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/server/bulk.ts b/packages/tests/src/api/server/bulk.ts new file mode 100644 index 000000000..725bcfef2 --- /dev/null +++ b/packages/tests/src/api/server/bulk.ts @@ -0,0 +1,185 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + BulkCommand, + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test bulk actions', function () { + const commentsUser3: { videoId: number, commentId: number }[] = [] + + let servers: PeerTubeServer[] = [] + let user1Token: string + let user2Token: string + let user3Token: string + + let bulkCommand: BulkCommand + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + + { + const user = { username: 'user1', password: 'password' } + await servers[0].users.create({ username: user.username, password: user.password }) + + user1Token = await servers[0].login.getAccessToken(user) + } + + { + const user = { username: 'user2', password: 'password' } + await servers[0].users.create({ username: user.username, password: user.password }) + + user2Token = await servers[0].login.getAccessToken(user) + } + + { + const user = { username: 'user3', password: 'password' } + await servers[1].users.create({ username: user.username, password: user.password }) + + user3Token = await servers[1].login.getAccessToken(user) + } + + await doubleFollow(servers[0], servers[1]) + + bulkCommand = new BulkCommand(servers[0]) + }) + + describe('Bulk remove comments', function () { + async function checkInstanceCommentsRemoved () { + { + const { data } = await servers[0].videos.list() + + // Server 1 should not have these comments anymore + for (const video of data) { + const { data } = await servers[0].comments.listThreads({ videoId: video.id }) + const comment = data.find(c => c.text === 'comment by user 3') + + expect(comment).to.not.exist + } + } + + { + const { data } = await servers[1].videos.list() + + // Server 1 should not have these comments on videos of server 1 + for (const video of data) { + const { data } = await servers[1].comments.listThreads({ videoId: video.id }) + const comment = data.find(c => c.text === 'comment by user 3') + + if (video.account.host === servers[0].host) { + expect(comment).to.not.exist + } else { + expect(comment).to.exist + } + } + } + } + + before(async function () { + this.timeout(240000) + + await servers[0].videos.upload({ attributes: { name: 'video 1 server 1' } }) + await servers[0].videos.upload({ attributes: { name: 'video 2 server 1' } }) + await servers[0].videos.upload({ token: user1Token, attributes: { name: 'video 3 server 1' } }) + + await servers[1].videos.upload({ attributes: { name: 'video 1 server 2' } }) + + await waitJobs(servers) + + { + const { data } = await servers[0].videos.list() + for (const video of data) { + await servers[0].comments.createThread({ videoId: video.id, text: 'comment by root server 1' }) + await servers[0].comments.createThread({ token: user1Token, videoId: video.id, text: 'comment by user 1' }) + await servers[0].comments.createThread({ token: user2Token, videoId: video.id, text: 'comment by user 2' }) + } + } + + { + const { data } = await servers[1].videos.list() + + for (const video of data) { + await servers[1].comments.createThread({ videoId: video.id, text: 'comment by root server 2' }) + + const comment = await servers[1].comments.createThread({ token: user3Token, videoId: video.id, text: 'comment by user 3' }) + commentsUser3.push({ videoId: video.id, commentId: comment.id }) + } + } + + await waitJobs(servers) + }) + + it('Should delete comments of an account on my videos', async function () { + this.timeout(60000) + + await bulkCommand.removeCommentsOf({ + token: user1Token, + attributes: { + accountName: 'user2', + scope: 'my-videos' + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + for (const video of data) { + const { data } = await server.comments.listThreads({ videoId: video.id }) + const comment = data.find(c => c.text === 'comment by user 2') + + if (video.name === 'video 3 server 1') expect(comment).to.not.exist + else expect(comment).to.exist + } + } + }) + + it('Should delete comments of an account on the instance', async function () { + this.timeout(60000) + + await bulkCommand.removeCommentsOf({ + attributes: { + accountName: 'user3@' + servers[1].host, + scope: 'instance' + } + }) + + await waitJobs(servers) + + await checkInstanceCommentsRemoved() + }) + + it('Should not re create the comment on video update', async function () { + this.timeout(60000) + + for (const obj of commentsUser3) { + await servers[1].comments.addReply({ + token: user3Token, + videoId: obj.videoId, + toCommentId: obj.commentId, + text: 'comment by user 3 bis' + }) + } + + await waitJobs(servers) + + await checkInstanceCommentsRemoved() + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/server/config-defaults.ts b/packages/tests/src/api/server/config-defaults.ts new file mode 100644 index 000000000..e874af012 --- /dev/null +++ b/packages/tests/src/api/server/config-defaults.ts @@ -0,0 +1,294 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' +import { FIXTURE_URLS } from '@tests/shared/tests.js' + +describe('Test config defaults', function () { + let server: PeerTubeServer + let channelId: number + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + channelId = server.store.channel.id + }) + + describe('Default publish values', function () { + + before(async function () { + const overrideConfig = { + defaults: { + publish: { + comments_enabled: false, + download_enabled: false, + privacy: VideoPrivacy.INTERNAL, + licence: 4 + } + } + } + + await server.kill() + await server.run(overrideConfig) + }) + + const attributes = { + name: 'video', + downloadEnabled: undefined, + commentsEnabled: undefined, + licence: undefined, + privacy: VideoPrivacy.PUBLIC // Privacy is mandatory for server + } + + function checkVideo (video: VideoDetails) { + expect(video.downloadEnabled).to.be.false + expect(video.commentsEnabled).to.be.false + expect(video.licence.id).to.equal(4) + } + + before(async function () { + await server.config.disableTranscoding() + await server.config.enableImports() + await server.config.enableLive({ allowReplay: false, transcoding: false }) + }) + + it('Should have the correct server configuration', async function () { + const config = await server.config.getConfig() + + expect(config.defaults.publish.commentsEnabled).to.be.false + expect(config.defaults.publish.downloadEnabled).to.be.false + expect(config.defaults.publish.licence).to.equal(4) + expect(config.defaults.publish.privacy).to.equal(VideoPrivacy.INTERNAL) + }) + + it('Should respect default values when uploading a video', async function () { + for (const mode of [ 'legacy' as 'legacy', 'resumable' as 'resumable' ]) { + const { id } = await server.videos.upload({ attributes, mode }) + + const video = await server.videos.get({ id }) + checkVideo(video) + } + }) + + it('Should respect default values when importing a video using URL', async function () { + const { video: { id } } = await server.imports.importVideo({ + attributes: { + ...attributes, + channelId, + targetUrl: FIXTURE_URLS.goodVideo + } + }) + + const video = await server.videos.get({ id }) + checkVideo(video) + }) + + it('Should respect default values when importing a video using magnet URI', async function () { + const { video: { id } } = await server.imports.importVideo({ + attributes: { + ...attributes, + channelId, + magnetUri: FIXTURE_URLS.magnet + } + }) + + const video = await server.videos.get({ id }) + checkVideo(video) + }) + + it('Should respect default values when creating a live', async function () { + const { id } = await server.live.create({ + fields: { + ...attributes, + channelId + } + }) + + const video = await server.videos.get({ id }) + checkVideo(video) + }) + }) + + describe('Default P2P values', function () { + + describe('Webapp default value', function () { + + before(async function () { + const overrideConfig = { + defaults: { + p2p: { + webapp: { + enabled: false + } + } + } + } + + await server.kill() + await server.run(overrideConfig) + }) + + it('Should have appropriate P2P config', async function () { + const config = await server.config.getConfig() + + expect(config.defaults.p2p.webapp.enabled).to.be.false + expect(config.defaults.p2p.embed.enabled).to.be.true + }) + + it('Should create a user with this default setting', async function () { + await server.users.create({ username: 'user_p2p_1' }) + const userToken = await server.login.getAccessToken('user_p2p_1') + + const { p2pEnabled } = await server.users.getMyInfo({ token: userToken }) + expect(p2pEnabled).to.be.false + }) + + it('Should register a user with this default setting', async function () { + await server.registrations.register({ username: 'user_p2p_2' }) + + const userToken = await server.login.getAccessToken('user_p2p_2') + + const { p2pEnabled } = await server.users.getMyInfo({ token: userToken }) + expect(p2pEnabled).to.be.false + }) + }) + + describe('Embed default value', function () { + + before(async function () { + const overrideConfig = { + defaults: { + p2p: { + embed: { + enabled: false + } + } + }, + signup: { + limit: 15 + } + } + + await server.kill() + await server.run(overrideConfig) + }) + + it('Should have appropriate P2P config', async function () { + const config = await server.config.getConfig() + + expect(config.defaults.p2p.webapp.enabled).to.be.true + expect(config.defaults.p2p.embed.enabled).to.be.false + }) + + it('Should create a user with this default setting', async function () { + await server.users.create({ username: 'user_p2p_3' }) + const userToken = await server.login.getAccessToken('user_p2p_3') + + const { p2pEnabled } = await server.users.getMyInfo({ token: userToken }) + expect(p2pEnabled).to.be.true + }) + + it('Should register a user with this default setting', async function () { + await server.registrations.register({ username: 'user_p2p_4' }) + + const userToken = await server.login.getAccessToken('user_p2p_4') + + const { p2pEnabled } = await server.users.getMyInfo({ token: userToken }) + expect(p2pEnabled).to.be.true + }) + }) + }) + + describe('Default user attributes', function () { + it('Should create a user and register a user with the default config', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + user: { + history: { + videos: { + enabled: true + } + }, + videoQuota : -1, + videoQuotaDaily: -1 + }, + signup: { + enabled: true, + requiresApproval: false + } + } + }) + + const config = await server.config.getConfig() + + expect(config.user.videoQuota).to.equal(-1) + expect(config.user.videoQuotaDaily).to.equal(-1) + + const user1Token = await server.users.generateUserAndToken('user1') + const user1 = await server.users.getMyInfo({ token: user1Token }) + + const user = { displayName: 'super user 2', username: 'user2', password: 'super password' } + const channel = { name: 'my_user_2_channel', displayName: 'my channel' } + await server.registrations.register({ ...user, channel }) + const user2Token = await server.login.getAccessToken(user) + const user2 = await server.users.getMyInfo({ token: user2Token }) + + for (const user of [ user1, user2 ]) { + expect(user.videosHistoryEnabled).to.be.true + expect(user.videoQuota).to.equal(-1) + expect(user.videoQuotaDaily).to.equal(-1) + } + }) + + it('Should update config and create a user and register a user with the new default config', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + user: { + history: { + videos: { + enabled: false + } + }, + videoQuota : 5242881, + videoQuotaDaily: 318742 + }, + signup: { + enabled: true, + requiresApproval: false + } + } + }) + + const user3Token = await server.users.generateUserAndToken('user3') + const user3 = await server.users.getMyInfo({ token: user3Token }) + + const user = { displayName: 'super user 4', username: 'user4', password: 'super password' } + const channel = { name: 'my_user_4_channel', displayName: 'my channel' } + await server.registrations.register({ ...user, channel }) + const user4Token = await server.login.getAccessToken(user) + const user4 = await server.users.getMyInfo({ token: user4Token }) + + for (const user of [ user3, user4 ]) { + expect(user.videosHistoryEnabled).to.be.false + expect(user.videoQuota).to.equal(5242881) + expect(user.videoQuotaDaily).to.equal(318742) + } + }) + + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/config.ts b/packages/tests/src/api/server/config.ts new file mode 100644 index 000000000..ce64668f8 --- /dev/null +++ b/packages/tests/src/api/server/config.ts @@ -0,0 +1,645 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { parallelTests } from '@peertube/peertube-node-utils' +import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + killallServers, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { + expect(data.instance.name).to.equal('PeerTube') + expect(data.instance.shortDescription).to.equal( + 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.' + ) + expect(data.instance.description).to.equal('Welcome to this PeerTube instance!') + + expect(data.instance.terms).to.equal('No terms for now.') + expect(data.instance.creationReason).to.be.empty + expect(data.instance.codeOfConduct).to.be.empty + expect(data.instance.moderationInformation).to.be.empty + expect(data.instance.administrator).to.be.empty + expect(data.instance.maintenanceLifetime).to.be.empty + expect(data.instance.businessModel).to.be.empty + expect(data.instance.hardwareInformation).to.be.empty + + expect(data.instance.languages).to.have.lengthOf(0) + expect(data.instance.categories).to.have.lengthOf(0) + + expect(data.instance.defaultClientRoute).to.equal('/videos/trending') + expect(data.instance.isNSFW).to.be.false + expect(data.instance.defaultNSFWPolicy).to.equal('display') + expect(data.instance.customizations.css).to.be.empty + expect(data.instance.customizations.javascript).to.be.empty + + expect(data.services.twitter.username).to.equal('@Chocobozzz') + expect(data.services.twitter.whitelisted).to.be.false + + expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.false + expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.false + + expect(data.cache.previews.size).to.equal(1) + expect(data.cache.captions.size).to.equal(1) + expect(data.cache.torrents.size).to.equal(1) + expect(data.cache.storyboards.size).to.equal(1) + + expect(data.signup.enabled).to.be.true + expect(data.signup.limit).to.equal(4) + expect(data.signup.minimumAge).to.equal(16) + expect(data.signup.requiresApproval).to.be.false + expect(data.signup.requiresEmailVerification).to.be.false + + expect(data.admin.email).to.equal('admin' + server.internalServerNumber + '@example.com') + expect(data.contactForm.enabled).to.be.true + + expect(data.user.history.videos.enabled).to.be.true + expect(data.user.videoQuota).to.equal(5242880) + expect(data.user.videoQuotaDaily).to.equal(-1) + + expect(data.videoChannels.maxPerUser).to.equal(20) + + expect(data.transcoding.enabled).to.be.false + expect(data.transcoding.remoteRunners.enabled).to.be.false + expect(data.transcoding.allowAdditionalExtensions).to.be.false + expect(data.transcoding.allowAudioFiles).to.be.false + expect(data.transcoding.threads).to.equal(2) + expect(data.transcoding.concurrency).to.equal(2) + expect(data.transcoding.profile).to.equal('default') + expect(data.transcoding.resolutions['144p']).to.be.false + expect(data.transcoding.resolutions['240p']).to.be.true + expect(data.transcoding.resolutions['360p']).to.be.true + expect(data.transcoding.resolutions['480p']).to.be.true + expect(data.transcoding.resolutions['720p']).to.be.true + expect(data.transcoding.resolutions['1080p']).to.be.true + expect(data.transcoding.resolutions['1440p']).to.be.true + expect(data.transcoding.resolutions['2160p']).to.be.true + expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true + expect(data.transcoding.webVideos.enabled).to.be.true + expect(data.transcoding.hls.enabled).to.be.true + + expect(data.live.enabled).to.be.false + expect(data.live.allowReplay).to.be.false + expect(data.live.latencySetting.enabled).to.be.true + expect(data.live.maxDuration).to.equal(-1) + expect(data.live.maxInstanceLives).to.equal(20) + expect(data.live.maxUserLives).to.equal(3) + expect(data.live.transcoding.enabled).to.be.false + expect(data.live.transcoding.remoteRunners.enabled).to.be.false + expect(data.live.transcoding.threads).to.equal(2) + expect(data.live.transcoding.profile).to.equal('default') + expect(data.live.transcoding.resolutions['144p']).to.be.false + expect(data.live.transcoding.resolutions['240p']).to.be.false + expect(data.live.transcoding.resolutions['360p']).to.be.false + expect(data.live.transcoding.resolutions['480p']).to.be.false + expect(data.live.transcoding.resolutions['720p']).to.be.false + expect(data.live.transcoding.resolutions['1080p']).to.be.false + expect(data.live.transcoding.resolutions['1440p']).to.be.false + expect(data.live.transcoding.resolutions['2160p']).to.be.false + expect(data.live.transcoding.alwaysTranscodeOriginalResolution).to.be.true + + expect(data.videoStudio.enabled).to.be.false + expect(data.videoStudio.remoteRunners.enabled).to.be.false + + expect(data.videoFile.update.enabled).to.be.false + + expect(data.import.videos.concurrency).to.equal(2) + expect(data.import.videos.http.enabled).to.be.true + expect(data.import.videos.torrent.enabled).to.be.true + expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.false + + expect(data.followers.instance.enabled).to.be.true + expect(data.followers.instance.manualApproval).to.be.false + + expect(data.followings.instance.autoFollowBack.enabled).to.be.false + expect(data.followings.instance.autoFollowIndex.enabled).to.be.false + expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('') + + expect(data.broadcastMessage.enabled).to.be.false + expect(data.broadcastMessage.level).to.equal('info') + expect(data.broadcastMessage.message).to.equal('') + expect(data.broadcastMessage.dismissable).to.be.false +} + +function checkUpdatedConfig (data: CustomConfig) { + expect(data.instance.name).to.equal('PeerTube updated') + expect(data.instance.shortDescription).to.equal('my short description') + expect(data.instance.description).to.equal('my super description') + + expect(data.instance.terms).to.equal('my super terms') + expect(data.instance.creationReason).to.equal('my super creation reason') + expect(data.instance.codeOfConduct).to.equal('my super coc') + expect(data.instance.moderationInformation).to.equal('my super moderation information') + expect(data.instance.administrator).to.equal('Kuja') + expect(data.instance.maintenanceLifetime).to.equal('forever') + expect(data.instance.businessModel).to.equal('my super business model') + expect(data.instance.hardwareInformation).to.equal('2vCore 3GB RAM') + + expect(data.instance.languages).to.deep.equal([ 'en', 'es' ]) + expect(data.instance.categories).to.deep.equal([ 1, 2 ]) + + expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added') + expect(data.instance.isNSFW).to.be.true + expect(data.instance.defaultNSFWPolicy).to.equal('blur') + expect(data.instance.customizations.javascript).to.equal('alert("coucou")') + expect(data.instance.customizations.css).to.equal('body { background-color: red; }') + + expect(data.services.twitter.username).to.equal('@Kuja') + expect(data.services.twitter.whitelisted).to.be.true + + expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.true + expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.true + + expect(data.cache.previews.size).to.equal(2) + expect(data.cache.captions.size).to.equal(3) + expect(data.cache.torrents.size).to.equal(4) + expect(data.cache.storyboards.size).to.equal(5) + + expect(data.signup.enabled).to.be.false + expect(data.signup.limit).to.equal(5) + expect(data.signup.requiresApproval).to.be.false + expect(data.signup.requiresEmailVerification).to.be.false + expect(data.signup.minimumAge).to.equal(10) + + // We override admin email in parallel tests, so skip this exception + if (parallelTests() === false) { + expect(data.admin.email).to.equal('superadmin1@example.com') + } + + expect(data.contactForm.enabled).to.be.false + + expect(data.user.history.videos.enabled).to.be.false + expect(data.user.videoQuota).to.equal(5242881) + expect(data.user.videoQuotaDaily).to.equal(318742) + + expect(data.videoChannels.maxPerUser).to.equal(24) + + expect(data.transcoding.enabled).to.be.true + expect(data.transcoding.remoteRunners.enabled).to.be.true + expect(data.transcoding.threads).to.equal(1) + expect(data.transcoding.concurrency).to.equal(3) + expect(data.transcoding.allowAdditionalExtensions).to.be.true + expect(data.transcoding.allowAudioFiles).to.be.true + expect(data.transcoding.profile).to.equal('vod_profile') + expect(data.transcoding.resolutions['144p']).to.be.false + expect(data.transcoding.resolutions['240p']).to.be.false + expect(data.transcoding.resolutions['360p']).to.be.true + expect(data.transcoding.resolutions['480p']).to.be.true + expect(data.transcoding.resolutions['720p']).to.be.false + expect(data.transcoding.resolutions['1080p']).to.be.false + expect(data.transcoding.resolutions['2160p']).to.be.false + expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.false + expect(data.transcoding.hls.enabled).to.be.false + expect(data.transcoding.webVideos.enabled).to.be.true + + expect(data.live.enabled).to.be.true + expect(data.live.allowReplay).to.be.true + expect(data.live.latencySetting.enabled).to.be.false + expect(data.live.maxDuration).to.equal(5000) + expect(data.live.maxInstanceLives).to.equal(-1) + expect(data.live.maxUserLives).to.equal(10) + expect(data.live.transcoding.enabled).to.be.true + expect(data.live.transcoding.remoteRunners.enabled).to.be.true + expect(data.live.transcoding.threads).to.equal(4) + expect(data.live.transcoding.profile).to.equal('live_profile') + expect(data.live.transcoding.resolutions['144p']).to.be.true + expect(data.live.transcoding.resolutions['240p']).to.be.true + expect(data.live.transcoding.resolutions['360p']).to.be.true + expect(data.live.transcoding.resolutions['480p']).to.be.true + expect(data.live.transcoding.resolutions['720p']).to.be.true + expect(data.live.transcoding.resolutions['1080p']).to.be.true + expect(data.live.transcoding.resolutions['2160p']).to.be.true + expect(data.live.transcoding.alwaysTranscodeOriginalResolution).to.be.false + + expect(data.videoStudio.enabled).to.be.true + expect(data.videoStudio.remoteRunners.enabled).to.be.true + + expect(data.videoFile.update.enabled).to.be.true + + expect(data.import.videos.concurrency).to.equal(4) + expect(data.import.videos.http.enabled).to.be.false + expect(data.import.videos.torrent.enabled).to.be.false + expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.true + + expect(data.followers.instance.enabled).to.be.false + expect(data.followers.instance.manualApproval).to.be.true + + expect(data.followings.instance.autoFollowBack.enabled).to.be.true + expect(data.followings.instance.autoFollowIndex.enabled).to.be.true + expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('https://updated.example.com') + + expect(data.broadcastMessage.enabled).to.be.true + expect(data.broadcastMessage.level).to.equal('error') + expect(data.broadcastMessage.message).to.equal('super bad message') + expect(data.broadcastMessage.dismissable).to.be.true +} + +const newCustomConfig: CustomConfig = { + instance: { + name: 'PeerTube updated', + shortDescription: 'my short description', + description: 'my super description', + terms: 'my super terms', + codeOfConduct: 'my super coc', + + creationReason: 'my super creation reason', + moderationInformation: 'my super moderation information', + administrator: 'Kuja', + maintenanceLifetime: 'forever', + businessModel: 'my super business model', + hardwareInformation: '2vCore 3GB RAM', + + languages: [ 'en', 'es' ], + categories: [ 1, 2 ], + + isNSFW: true, + defaultNSFWPolicy: 'blur' as 'blur', + + defaultClientRoute: '/videos/recently-added', + + customizations: { + javascript: 'alert("coucou")', + css: 'body { background-color: red; }' + } + }, + theme: { + default: 'default' + }, + services: { + twitter: { + username: '@Kuja', + whitelisted: true + } + }, + client: { + videos: { + miniature: { + preferAuthorDisplayName: true + } + }, + menu: { + login: { + redirectOnSingleExternalAuth: true + } + } + }, + cache: { + previews: { + size: 2 + }, + captions: { + size: 3 + }, + torrents: { + size: 4 + }, + storyboards: { + size: 5 + } + }, + signup: { + enabled: false, + limit: 5, + requiresApproval: false, + requiresEmailVerification: false, + minimumAge: 10 + }, + admin: { + email: 'superadmin1@example.com' + }, + contactForm: { + enabled: false + }, + user: { + history: { + videos: { + enabled: false + } + }, + videoQuota: 5242881, + videoQuotaDaily: 318742 + }, + videoChannels: { + maxPerUser: 24 + }, + transcoding: { + enabled: true, + remoteRunners: { + enabled: true + }, + allowAdditionalExtensions: true, + allowAudioFiles: true, + threads: 1, + concurrency: 3, + profile: 'vod_profile', + resolutions: { + '0p': false, + '144p': false, + '240p': false, + '360p': true, + '480p': true, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + alwaysTranscodeOriginalResolution: false, + webVideos: { + enabled: true + }, + hls: { + enabled: false + } + }, + live: { + enabled: true, + allowReplay: true, + latencySetting: { + enabled: false + }, + maxDuration: 5000, + maxInstanceLives: -1, + maxUserLives: 10, + transcoding: { + enabled: true, + remoteRunners: { + enabled: true + }, + threads: 4, + profile: 'live_profile', + resolutions: { + '144p': true, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + }, + alwaysTranscodeOriginalResolution: false + } + }, + videoStudio: { + enabled: true, + remoteRunners: { + enabled: true + } + }, + videoFile: { + update: { + enabled: true + } + }, + import: { + videos: { + concurrency: 4, + http: { + enabled: false + }, + torrent: { + enabled: false + } + }, + videoChannelSynchronization: { + enabled: false, + maxPerUser: 10 + } + }, + trending: { + videos: { + algorithms: { + enabled: [ 'hot', 'most-viewed', 'most-liked' ], + default: 'hot' + } + } + }, + autoBlacklist: { + videos: { + ofUsers: { + enabled: true + } + } + }, + followers: { + instance: { + enabled: false, + manualApproval: true + } + }, + followings: { + instance: { + autoFollowBack: { + enabled: true + }, + autoFollowIndex: { + enabled: true, + indexUrl: 'https://updated.example.com' + } + } + }, + broadcastMessage: { + enabled: true, + level: 'error', + message: 'super bad message', + dismissable: true + }, + search: { + remoteUri: { + anonymous: true, + users: true + }, + searchIndex: { + enabled: true, + url: 'https://search.joinpeertube.org', + disableLocalSearch: true, + isDefaultSearch: true + } + } +} + +describe('Test static config', function () { + let server: PeerTubeServer = null + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, { webadmin: { configuration: { edition: { allowed: false } } } }) + await setAccessTokensToServers([ server ]) + }) + + it('Should tell the client that edits are not allowed', async function () { + const data = await server.config.getConfig() + + expect(data.webadmin.configuration.edition.allowed).to.be.false + }) + + it('Should error when client tries to update', async function () { + await server.config.updateCustomConfig({ newCustomConfig, expectedStatus: 405 }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) + +describe('Test config', function () { + let server: PeerTubeServer = null + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + }) + + it('Should have a correct config on a server with registration enabled', async function () { + const data = await server.config.getConfig() + + expect(data.signup.allowed).to.be.true + }) + + it('Should have a correct config on a server with registration enabled and a users limit', async function () { + this.timeout(5000) + + await Promise.all([ + server.registrations.register({ username: 'user1' }), + server.registrations.register({ username: 'user2' }), + server.registrations.register({ username: 'user3' }) + ]) + + const data = await server.config.getConfig() + + expect(data.signup.allowed).to.be.false + }) + + it('Should have the correct video allowed extensions', async function () { + const data = await server.config.getConfig() + + expect(data.video.file.extensions).to.have.lengthOf(3) + expect(data.video.file.extensions).to.contain('.mp4') + expect(data.video.file.extensions).to.contain('.webm') + expect(data.video.file.extensions).to.contain('.ogv') + + await server.videos.upload({ attributes: { fixture: 'video_short.mkv' }, expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 }) + await server.videos.upload({ attributes: { fixture: 'sample.ogg' }, expectedStatus: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 }) + + expect(data.contactForm.enabled).to.be.true + }) + + it('Should get the customized configuration', async function () { + const data = await server.config.getCustomConfig() + + checkInitialConfig(server, data) + }) + + it('Should update the customized configuration', async function () { + await server.config.updateCustomConfig({ newCustomConfig }) + + const data = await server.config.getCustomConfig() + checkUpdatedConfig(data) + }) + + it('Should have the correct updated video allowed extensions', async function () { + this.timeout(30000) + + const data = await server.config.getConfig() + + expect(data.video.file.extensions).to.have.length.above(4) + expect(data.video.file.extensions).to.contain('.mp4') + expect(data.video.file.extensions).to.contain('.webm') + expect(data.video.file.extensions).to.contain('.ogv') + expect(data.video.file.extensions).to.contain('.flv') + expect(data.video.file.extensions).to.contain('.wmv') + expect(data.video.file.extensions).to.contain('.mkv') + expect(data.video.file.extensions).to.contain('.mp3') + expect(data.video.file.extensions).to.contain('.ogg') + expect(data.video.file.extensions).to.contain('.flac') + + await server.videos.upload({ attributes: { fixture: 'video_short.mkv' }, expectedStatus: HttpStatusCode.OK_200 }) + await server.videos.upload({ attributes: { fixture: 'sample.ogg' }, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should have the configuration updated after a restart', async function () { + this.timeout(30000) + + await killallServers([ server ]) + + await server.run() + + const data = await server.config.getCustomConfig() + + checkUpdatedConfig(data) + }) + + it('Should fetch the about information', async function () { + const data = await server.config.getAbout() + + expect(data.instance.name).to.equal('PeerTube updated') + expect(data.instance.shortDescription).to.equal('my short description') + expect(data.instance.description).to.equal('my super description') + expect(data.instance.terms).to.equal('my super terms') + expect(data.instance.codeOfConduct).to.equal('my super coc') + + expect(data.instance.creationReason).to.equal('my super creation reason') + expect(data.instance.moderationInformation).to.equal('my super moderation information') + expect(data.instance.administrator).to.equal('Kuja') + expect(data.instance.maintenanceLifetime).to.equal('forever') + expect(data.instance.businessModel).to.equal('my super business model') + expect(data.instance.hardwareInformation).to.equal('2vCore 3GB RAM') + + expect(data.instance.languages).to.deep.equal([ 'en', 'es' ]) + expect(data.instance.categories).to.deep.equal([ 1, 2 ]) + }) + + it('Should remove the custom configuration', async function () { + await server.config.deleteCustomConfig() + + const data = await server.config.getCustomConfig() + checkInitialConfig(server, data) + }) + + it('Should enable/disable security headers', async function () { + this.timeout(25000) + + { + const res = await makeGetRequest({ + url: server.url, + path: '/api/v1/config', + expectedStatus: 200 + }) + + expect(res.headers['x-frame-options']).to.exist + expect(res.headers['x-powered-by']).to.equal('PeerTube') + } + + await killallServers([ server ]) + + const config = { + security: { + frameguard: { enabled: false }, + powered_by_header: { enabled: false } + } + } + await server.run(config) + + { + const res = await makeGetRequest({ + url: server.url, + path: '/api/v1/config', + expectedStatus: 200 + }) + + expect(res.headers['x-frame-options']).to.not.exist + expect(res.headers['x-powered-by']).to.not.exist + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/contact-form.ts b/packages/tests/src/api/server/contact-form.ts new file mode 100644 index 000000000..03389aa64 --- /dev/null +++ b/packages/tests/src/api/server/contact-form.ts @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + ConfigCommand, + ContactFormCommand, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test contact form', function () { + let server: PeerTubeServer + const emails: object[] = [] + let command: ContactFormCommand + + before(async function () { + this.timeout(30000) + + const port = await MockSmtpServer.Instance.collectEmails(emails) + + server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port)) + await setAccessTokensToServers([ server ]) + + command = server.contactForm + }) + + it('Should send a contact form', async function () { + await command.send({ + fromEmail: 'toto@example.com', + body: 'my super message', + subject: 'my subject', + fromName: 'Super toto' + }) + + await waitJobs(server) + + expect(emails).to.have.lengthOf(1) + + const email = emails[0] + + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['replyTo'][0]['address']).equal('toto@example.com') + expect(email['to'][0]['address']).equal('admin' + server.internalServerNumber + '@example.com') + expect(email['subject']).contains('my subject') + expect(email['text']).contains('my super message') + }) + + it('Should not have duplicated email address in text message', async function () { + const text = emails[0]['text'] as string + + const matches = text.match(/toto@example.com/g) + expect(matches).to.have.lengthOf(1) + }) + + it('Should not be able to send another contact form because of the anti spam checker', async function () { + await wait(1000) + + await command.send({ + fromEmail: 'toto@example.com', + body: 'my super message', + subject: 'my subject', + fromName: 'Super toto' + }) + + await command.send({ + fromEmail: 'toto@example.com', + body: 'my super message', + fromName: 'Super toto', + subject: 'my subject', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should be able to send another contact form after a while', async function () { + await wait(1000) + + await command.send({ + fromEmail: 'toto@example.com', + fromName: 'Super toto', + subject: 'my subject', + body: 'my super message' + }) + }) + + it('Should not have the manage preferences link in the email', async function () { + const email = emails[0] + expect(email['text']).to.not.contain('Manage your notification preferences') + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/email.ts b/packages/tests/src/api/server/email.ts new file mode 100644 index 000000000..6d3f3f3bb --- /dev/null +++ b/packages/tests/src/api/server/email.ts @@ -0,0 +1,371 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + ConfigCommand, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test emails', function () { + let server: PeerTubeServer + let userId: number + let userId2: number + let userAccessToken: string + + let videoShortUUID: string + let videoId: number + + let videoUserUUID: string + + let verificationString: string + let verificationString2: string + + const emails: object[] = [] + const user = { + username: 'user_1', + password: 'super_password' + } + + before(async function () { + this.timeout(120000) + + const emailPort = await MockSmtpServer.Instance.collectEmails(emails) + server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort)) + + await setAccessTokensToServers([ server ]) + await server.config.enableSignup(true) + + { + const created = await server.users.create({ username: user.username, password: user.password }) + userId = created.id + + userAccessToken = await server.login.getAccessToken(user) + } + + { + const attributes = { name: 'my super user video' } + const { uuid } = await server.videos.upload({ token: userAccessToken, attributes }) + videoUserUUID = uuid + } + + { + const attributes = { + name: 'my super name' + } + const { shortUUID, id } = await server.videos.upload({ attributes }) + videoShortUUID = shortUUID + videoId = id + } + }) + + describe('When resetting user password', function () { + + it('Should ask to reset the password', async function () { + await server.users.askResetPassword({ email: 'user_1@example.com' }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(1) + + const email = emails[0] + + expect(email['from'][0]['name']).equal('PeerTube') + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['to'][0]['address']).equal('user_1@example.com') + expect(email['subject']).contains('password') + + const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text']) + expect(verificationStringMatches).not.to.be.null + + verificationString = verificationStringMatches[1] + expect(verificationString).to.have.length.above(2) + + const userIdMatches = /userId=([0-9]+)/.exec(email['text']) + expect(userIdMatches).not.to.be.null + + userId = parseInt(userIdMatches[1], 10) + expect(verificationString).to.not.be.undefined + }) + + it('Should not reset the password with an invalid verification string', async function () { + await server.users.resetPassword({ + userId, + verificationString: verificationString + 'b', + password: 'super_password2', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should reset the password', async function () { + await server.users.resetPassword({ userId, verificationString, password: 'super_password2' }) + }) + + it('Should not reset the password with the same verification string', async function () { + await server.users.resetPassword({ + userId, + verificationString, + password: 'super_password3', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should login with this new password', async function () { + user.password = 'super_password2' + + await server.login.getAccessToken(user) + }) + }) + + describe('When creating a user without password', function () { + + it('Should send a create password email', async function () { + await server.users.create({ username: 'create_password', password: '' }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(2) + + const email = emails[1] + + expect(email['from'][0]['name']).equal('PeerTube') + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['to'][0]['address']).equal('create_password@example.com') + expect(email['subject']).contains('account') + expect(email['subject']).contains('password') + + const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text']) + expect(verificationStringMatches).not.to.be.null + + verificationString2 = verificationStringMatches[1] + expect(verificationString2).to.have.length.above(2) + + const userIdMatches = /userId=([0-9]+)/.exec(email['text']) + expect(userIdMatches).not.to.be.null + + userId2 = parseInt(userIdMatches[1], 10) + }) + + it('Should not reset the password with an invalid verification string', async function () { + await server.users.resetPassword({ + userId: userId2, + verificationString: verificationString2 + 'c', + password: 'newly_created_password', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should reset the password', async function () { + await server.users.resetPassword({ + userId: userId2, + verificationString: verificationString2, + password: 'newly_created_password' + }) + }) + + it('Should login with this new password', async function () { + await server.login.getAccessToken({ + username: 'create_password', + password: 'newly_created_password' + }) + }) + }) + + describe('When creating an abuse', function () { + + it('Should send the notification email', async function () { + const reason = 'my super bad reason' + await server.abuses.report({ token: userAccessToken, videoId, reason }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(3) + + const email = emails[2] + + expect(email['from'][0]['name']).equal('PeerTube') + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['to'][0]['address']).equal('admin' + server.internalServerNumber + '@example.com') + expect(email['subject']).contains('abuse') + expect(email['text']).contains(videoShortUUID) + }) + }) + + describe('When blocking/unblocking user', function () { + + it('Should send the notification email when blocking a user', async function () { + const reason = 'my super bad reason' + await server.users.banUser({ userId, reason }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(4) + + const email = emails[3] + + expect(email['from'][0]['name']).equal('PeerTube') + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['to'][0]['address']).equal('user_1@example.com') + expect(email['subject']).contains(' blocked') + expect(email['text']).contains(' blocked') + expect(email['text']).contains('bad reason') + }) + + it('Should send the notification email when unblocking a user', async function () { + await server.users.unbanUser({ userId }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(5) + + const email = emails[4] + + expect(email['from'][0]['name']).equal('PeerTube') + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['to'][0]['address']).equal('user_1@example.com') + expect(email['subject']).contains(' unblocked') + expect(email['text']).contains(' unblocked') + }) + }) + + describe('When blacklisting a video', function () { + it('Should send the notification email', async function () { + const reason = 'my super reason' + await server.blacklist.add({ videoId: videoUserUUID, reason }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(6) + + const email = emails[5] + + expect(email['from'][0]['name']).equal('PeerTube') + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['to'][0]['address']).equal('user_1@example.com') + expect(email['subject']).contains(' blacklisted') + expect(email['text']).contains('my super user video') + expect(email['text']).contains('my super reason') + }) + + it('Should send the notification email', async function () { + await server.blacklist.remove({ videoId: videoUserUUID }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(7) + + const email = emails[6] + + expect(email['from'][0]['name']).equal('PeerTube') + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['to'][0]['address']).equal('user_1@example.com') + expect(email['subject']).contains(' unblacklisted') + expect(email['text']).contains('my super user video') + }) + + it('Should have the manage preferences link in the email', async function () { + const email = emails[6] + expect(email['text']).to.contain('Manage your notification preferences') + }) + }) + + describe('When verifying a user email', function () { + + it('Should ask to send the verification email', async function () { + await server.users.askSendVerifyEmail({ email: 'user_1@example.com' }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(8) + + const email = emails[7] + + expect(email['from'][0]['name']).equal('PeerTube') + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['to'][0]['address']).equal('user_1@example.com') + expect(email['subject']).contains('Verify') + + const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text']) + expect(verificationStringMatches).not.to.be.null + + verificationString = verificationStringMatches[1] + expect(verificationString).to.not.be.undefined + expect(verificationString).to.have.length.above(2) + + const userIdMatches = /userId=([0-9]+)/.exec(email['text']) + expect(userIdMatches).not.to.be.null + + userId = parseInt(userIdMatches[1], 10) + }) + + it('Should not verify the email with an invalid verification string', async function () { + await server.users.verifyEmail({ + userId, + verificationString: verificationString + 'b', + isPendingEmail: false, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should verify the email', async function () { + await server.users.verifyEmail({ userId, verificationString }) + }) + }) + + describe('When verifying a registration email', function () { + let registrationId: number + let registrationIdEmail: number + + before(async function () { + const { id } = await server.registrations.requestRegistration({ + username: 'request_1', + email: 'request_1@example.com', + registrationReason: 'tt' + }) + registrationId = id + }) + + it('Should ask to send the verification email', async function () { + await server.registrations.askSendVerifyEmail({ email: 'request_1@example.com' }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(9) + + const email = emails[8] + + expect(email['from'][0]['name']).equal('PeerTube') + expect(email['from'][0]['address']).equal('test-admin@127.0.0.1') + expect(email['to'][0]['address']).equal('request_1@example.com') + expect(email['subject']).contains('Verify') + + const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text']) + expect(verificationStringMatches).not.to.be.null + + verificationString = verificationStringMatches[1] + expect(verificationString).to.not.be.undefined + expect(verificationString).to.have.length.above(2) + + const registrationIdMatches = /registrationId=([0-9]+)/.exec(email['text']) + expect(registrationIdMatches).not.to.be.null + + registrationIdEmail = parseInt(registrationIdMatches[1], 10) + + expect(registrationId).to.equal(registrationIdEmail) + }) + + it('Should not verify the email with an invalid verification string', async function () { + await server.registrations.verifyEmail({ + registrationId: registrationIdEmail, + verificationString: verificationString + 'b', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should verify the email', async function () { + await server.registrations.verifyEmail({ registrationId: registrationIdEmail, verificationString }) + }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/follow-constraints.ts b/packages/tests/src/api/server/follow-constraints.ts new file mode 100644 index 000000000..8d277c906 --- /dev/null +++ b/packages/tests/src/api/server/follow-constraints.ts @@ -0,0 +1,321 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode, PeerTubeProblemDocument, ServerErrorCode } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test follow constraints', function () { + let servers: PeerTubeServer[] = [] + let video1UUID: string + let video2UUID: string + let userToken: string + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + + { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video server 1' } }) + video1UUID = uuid + } + { + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'video server 2' } }) + video2UUID = uuid + } + + const user = { + username: 'user1', + password: 'super_password' + } + await servers[0].users.create({ username: user.username, password: user.password }) + userToken = await servers[0].login.getAccessToken(user) + + await doubleFollow(servers[0], servers[1]) + }) + + describe('With a followed instance', function () { + + describe('With an unlogged user', function () { + + it('Should get the local video', async function () { + await servers[0].videos.get({ id: video1UUID }) + }) + + it('Should get the remote video', async function () { + await servers[0].videos.get({ id: video2UUID }) + }) + + it('Should list local account videos', async function () { + const { total, data } = await servers[0].videos.listByAccount({ handle: 'root@' + servers[0].host }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should list remote account videos', async function () { + const { total, data } = await servers[0].videos.listByAccount({ handle: 'root@' + servers[1].host }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should list local channel videos', async function () { + const handle = 'root_channel@' + servers[0].host + const { total, data } = await servers[0].videos.listByChannel({ handle }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should list remote channel videos', async function () { + const handle = 'root_channel@' + servers[1].host + const { total, data } = await servers[0].videos.listByChannel({ handle }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + }) + + describe('With a logged user', function () { + it('Should get the local video', async function () { + await servers[0].videos.getWithToken({ token: userToken, id: video1UUID }) + }) + + it('Should get the remote video', async function () { + await servers[0].videos.getWithToken({ token: userToken, id: video2UUID }) + }) + + it('Should list local account videos', async function () { + const { total, data } = await servers[0].videos.listByAccount({ token: userToken, handle: 'root@' + servers[0].host }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should list remote account videos', async function () { + const { total, data } = await servers[0].videos.listByAccount({ token: userToken, handle: 'root@' + servers[1].host }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should list local channel videos', async function () { + const handle = 'root_channel@' + servers[0].host + const { total, data } = await servers[0].videos.listByChannel({ token: userToken, handle }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should list remote channel videos', async function () { + const handle = 'root_channel@' + servers[1].host + const { total, data } = await servers[0].videos.listByChannel({ token: userToken, handle }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + }) + }) + + describe('With a non followed instance', function () { + + before(async function () { + this.timeout(30000) + + await servers[0].follows.unfollow({ target: servers[1] }) + }) + + describe('With an unlogged user', function () { + + it('Should get the local video', async function () { + await servers[0].videos.get({ id: video1UUID }) + }) + + it('Should not get the remote video', async function () { + const body = await servers[0].videos.get({ id: video2UUID, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + const error = body as unknown as PeerTubeProblemDocument + + const doc = 'https://docs.joinpeertube.org/api-rest-reference.html#section/Errors/does_not_respect_follow_constraints' + expect(error.type).to.equal(doc) + expect(error.code).to.equal(ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS) + + expect(error.detail).to.equal('Cannot get this video regarding follow constraints') + expect(error.error).to.equal(error.detail) + + expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403) + + expect(error.originUrl).to.contains(servers[1].url) + }) + + it('Should list local account videos', async function () { + const { total, data } = await servers[0].videos.listByAccount({ + token: null, + handle: 'root@' + servers[0].host + }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should not list remote account videos', async function () { + const { total, data } = await servers[0].videos.listByAccount({ + token: null, + handle: 'root@' + servers[1].host + }) + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + }) + + it('Should list local channel videos', async function () { + const handle = 'root_channel@' + servers[0].host + const { total, data } = await servers[0].videos.listByChannel({ token: null, handle }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should not list remote channel videos', async function () { + const handle = 'root_channel@' + servers[1].host + const { total, data } = await servers[0].videos.listByChannel({ token: null, handle }) + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + }) + }) + + describe('With a logged user', function () { + + it('Should get the local video', async function () { + await servers[0].videos.getWithToken({ token: userToken, id: video1UUID }) + }) + + it('Should get the remote video', async function () { + await servers[0].videos.getWithToken({ token: userToken, id: video2UUID }) + }) + + it('Should list local account videos', async function () { + const { total, data } = await servers[0].videos.listByAccount({ token: userToken, handle: 'root@' + servers[0].host }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should list remote account videos', async function () { + const { total, data } = await servers[0].videos.listByAccount({ token: userToken, handle: 'root@' + servers[1].host }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should list local channel videos', async function () { + const handle = 'root_channel@' + servers[0].host + const { total, data } = await servers[0].videos.listByChannel({ token: userToken, handle }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + it('Should list remote channel videos', async function () { + const handle = 'root_channel@' + servers[1].host + const { total, data } = await servers[0].videos.listByChannel({ token: userToken, handle }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + }) + }) + + describe('When following a remote account', function () { + + before(async function () { + this.timeout(60000) + + await servers[0].follows.follow({ handles: [ 'root@' + servers[1].host ] }) + await waitJobs(servers) + }) + + it('Should get the remote video with an unlogged user', async function () { + await servers[0].videos.get({ id: video2UUID }) + }) + + it('Should get the remote video with a logged in user', async function () { + await servers[0].videos.getWithToken({ token: userToken, id: video2UUID }) + }) + }) + + describe('When unfollowing a remote account', function () { + + before(async function () { + this.timeout(60000) + + await servers[0].follows.unfollow({ target: 'root@' + servers[1].host }) + await waitJobs(servers) + }) + + it('Should not get the remote video with an unlogged user', async function () { + const body = await servers[0].videos.get({ id: video2UUID, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + + const error = body as unknown as PeerTubeProblemDocument + expect(error.code).to.equal(ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS) + }) + + it('Should get the remote video with a logged in user', async function () { + await servers[0].videos.getWithToken({ token: userToken, id: video2UUID }) + }) + }) + + describe('When following a remote channel', function () { + + before(async function () { + this.timeout(60000) + + await servers[0].follows.follow({ handles: [ 'root_channel@' + servers[1].host ] }) + await waitJobs(servers) + }) + + it('Should get the remote video with an unlogged user', async function () { + await servers[0].videos.get({ id: video2UUID }) + }) + + it('Should get the remote video with a logged in user', async function () { + await servers[0].videos.getWithToken({ token: userToken, id: video2UUID }) + }) + }) + + describe('When unfollowing a remote channel', function () { + + before(async function () { + this.timeout(60000) + + await servers[0].follows.unfollow({ target: 'root_channel@' + servers[1].host }) + await waitJobs(servers) + }) + + it('Should not get the remote video with an unlogged user', async function () { + const body = await servers[0].videos.get({ id: video2UUID, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + + const error = body as unknown as PeerTubeProblemDocument + expect(error.code).to.equal(ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS) + }) + + it('Should get the remote video with a logged in user', async function () { + await servers[0].videos.getWithToken({ token: userToken, id: video2UUID }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/server/follows-moderation.ts b/packages/tests/src/api/server/follows-moderation.ts new file mode 100644 index 000000000..811dd5c22 --- /dev/null +++ b/packages/tests/src/api/server/follows-moderation.ts @@ -0,0 +1,364 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { expectStartWith } from '@tests/shared/checks.js' +import { ActorFollow, FollowState } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + FollowsCommand, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +async function checkServer1And2HasFollowers (servers: PeerTubeServer[], state = 'accepted') { + const fns = [ + servers[0].follows.getFollowings.bind(servers[0].follows), + servers[1].follows.getFollowers.bind(servers[1].follows) + ] + + for (const fn of fns) { + const body = await fn({ start: 0, count: 5, sort: 'createdAt' }) + expect(body.total).to.equal(1) + + const follow = body.data[0] + expect(follow.state).to.equal(state) + expect(follow.follower.url).to.equal(servers[0].url + '/accounts/peertube') + expect(follow.following.url).to.equal(servers[1].url + '/accounts/peertube') + } +} + +async function checkFollows (options: { + follower: PeerTubeServer + followerState: FollowState | 'deleted' + + following: PeerTubeServer + followingState: FollowState | 'deleted' +}) { + const { follower, followerState, followingState, following } = options + + const followerUrl = follower.url + '/accounts/peertube' + const followingUrl = following.url + '/accounts/peertube' + const finder = (d: ActorFollow) => d.follower.url === followerUrl && d.following.url === followingUrl + + { + const { data } = await follower.follows.getFollowings() + const follow = data.find(finder) + + if (followerState === 'deleted') { + expect(follow).to.not.exist + } else { + expect(follow.state).to.equal(followerState) + expect(follow.follower.url).to.equal(followerUrl) + expect(follow.following.url).to.equal(followingUrl) + } + } + + { + const { data } = await following.follows.getFollowers() + const follow = data.find(finder) + + if (followingState === 'deleted') { + expect(follow).to.not.exist + } else { + expect(follow.state).to.equal(followingState) + expect(follow.follower.url).to.equal(followerUrl) + expect(follow.following.url).to.equal(followingUrl) + } + } +} + +async function checkNoFollowers (servers: PeerTubeServer[]) { + const fns = [ + servers[0].follows.getFollowings.bind(servers[0].follows), + servers[1].follows.getFollowers.bind(servers[1].follows) + ] + + for (const fn of fns) { + const body = await fn({ start: 0, count: 5, sort: 'createdAt', state: 'accepted' }) + expect(body.total).to.equal(0) + } +} + +describe('Test follows moderation', function () { + let servers: PeerTubeServer[] = [] + let commands: FollowsCommand[] + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + + commands = servers.map(s => s.follows) + }) + + describe('Default behaviour', function () { + + it('Should have server 1 following server 2', async function () { + this.timeout(30000) + + await commands[0].follow({ hosts: [ servers[1].url ] }) + + await waitJobs(servers) + }) + + it('Should have correct follows', async function () { + await checkServer1And2HasFollowers(servers) + }) + + it('Should remove follower on server 2', async function () { + await commands[1].removeFollower({ follower: servers[0] }) + + await waitJobs(servers) + }) + + it('Should not not have follows anymore', async function () { + await checkNoFollowers(servers) + }) + }) + + describe('Disabled/Enabled followers', function () { + + it('Should disable followers on server 2', async function () { + const subConfig = { + followers: { + instance: { + enabled: false, + manualApproval: false + } + } + } + + await servers[1].config.updateCustomSubConfig({ newConfig: subConfig }) + + await commands[0].follow({ hosts: [ servers[1].url ] }) + await waitJobs(servers) + + await checkNoFollowers(servers) + }) + + it('Should re enable followers on server 2', async function () { + const subConfig = { + followers: { + instance: { + enabled: true, + manualApproval: false + } + } + } + + await servers[1].config.updateCustomSubConfig({ newConfig: subConfig }) + + await commands[0].follow({ hosts: [ servers[1].url ] }) + await waitJobs(servers) + + await checkServer1And2HasFollowers(servers) + }) + }) + + describe('Manual approbation', function () { + + it('Should manually approve followers', async function () { + this.timeout(20000) + + await commands[0].unfollow({ target: servers[1] }) + await waitJobs(servers) + + const subConfig = { + followers: { + instance: { + enabled: true, + manualApproval: true + } + } + } + + await servers[1].config.updateCustomSubConfig({ newConfig: subConfig }) + await servers[2].config.updateCustomSubConfig({ newConfig: subConfig }) + + await commands[0].follow({ hosts: [ servers[1].url ] }) + await waitJobs(servers) + + await checkServer1And2HasFollowers(servers, 'pending') + }) + + it('Should accept a follower', async function () { + await commands[1].acceptFollower({ follower: 'peertube@' + servers[0].host }) + await waitJobs(servers) + + await checkServer1And2HasFollowers(servers) + }) + + it('Should reject another follower', async function () { + this.timeout(20000) + + await commands[0].follow({ hosts: [ servers[2].url ] }) + await waitJobs(servers) + + { + const body = await commands[0].getFollowings() + expect(body.total).to.equal(2) + } + + { + const body = await commands[1].getFollowers() + expect(body.total).to.equal(1) + } + + { + const body = await commands[2].getFollowers() + expect(body.total).to.equal(1) + } + + await commands[2].rejectFollower({ follower: 'peertube@' + servers[0].host }) + await waitJobs(servers) + + { // server 1 + { + const { data } = await commands[0].getFollowings({ state: 'accepted' }) + expect(data).to.have.lengthOf(1) + } + + { + const { data } = await commands[0].getFollowings({ state: 'rejected' }) + expect(data).to.have.lengthOf(1) + expectStartWith(data[0].following.url, servers[2].url) + } + } + + { // server 3 + { + const { data } = await commands[2].getFollowers({ state: 'accepted' }) + expect(data).to.have.lengthOf(0) + } + + { + const { data } = await commands[2].getFollowers({ state: 'rejected' }) + expect(data).to.have.lengthOf(1) + expectStartWith(data[0].follower.url, servers[0].url) + } + } + }) + + it('Should still auto accept channel followers', async function () { + await commands[0].follow({ handles: [ 'root_channel@' + servers[1].host ] }) + + await waitJobs(servers) + + const body = await commands[0].getFollowings() + const follow = body.data[0] + expect(follow.following.name).to.equal('root_channel') + expect(follow.state).to.equal('accepted') + }) + }) + + describe('Accept/reject state', function () { + + it('Should not change the follow on refollow with and without auto accept', async function () { + const run = async () => { + await commands[0].follow({ hosts: [ servers[2].url ] }) + await waitJobs(servers) + + await checkFollows({ + follower: servers[0], + followerState: 'rejected', + following: servers[2], + followingState: 'rejected' + }) + } + + await servers[2].config.updateExistingSubConfig({ newConfig: { followers: { instance: { manualApproval: false } } } }) + await run() + + await servers[2].config.updateExistingSubConfig({ newConfig: { followers: { instance: { manualApproval: true } } } }) + await run() + }) + + it('Should not change the rejected status on unfollow', async function () { + await commands[0].unfollow({ target: servers[2] }) + await waitJobs(servers) + + await checkFollows({ + follower: servers[0], + followerState: 'deleted', + following: servers[2], + followingState: 'rejected' + }) + }) + + it('Should delete the follower and add again the follower', async function () { + await commands[2].removeFollower({ follower: servers[0] }) + await waitJobs(servers) + + await commands[0].follow({ hosts: [ servers[2].url ] }) + await waitJobs(servers) + + await checkFollows({ + follower: servers[0], + followerState: 'pending', + following: servers[2], + followingState: 'pending' + }) + }) + + it('Should be able to reject a previously accepted follower', async function () { + await commands[1].rejectFollower({ follower: 'peertube@' + servers[0].host }) + await waitJobs(servers) + + await checkFollows({ + follower: servers[0], + followerState: 'rejected', + following: servers[1], + followingState: 'rejected' + }) + }) + + it('Should be able to re accept a previously rejected follower', async function () { + await commands[1].acceptFollower({ follower: 'peertube@' + servers[0].host }) + await waitJobs(servers) + + await checkFollows({ + follower: servers[0], + followerState: 'accepted', + following: servers[1], + followingState: 'accepted' + }) + }) + }) + + describe('Muted servers', function () { + + it('Should ignore follow requests of muted servers', async function () { + await servers[1].blocklist.addToServerBlocklist({ server: servers[0].host }) + + await commands[0].unfollow({ target: servers[1] }) + + await waitJobs(servers) + + await checkFollows({ + follower: servers[0], + followerState: 'deleted', + following: servers[1], + followingState: 'deleted' + }) + + await commands[0].follow({ hosts: [ servers[1].host ] }) + await waitJobs(servers) + + await checkFollows({ + follower: servers[0], + followerState: 'rejected', + following: servers[1], + followingState: 'deleted' + }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/server/follows.ts b/packages/tests/src/api/server/follows.ts new file mode 100644 index 000000000..fbe2e87da --- /dev/null +++ b/packages/tests/src/api/server/follows.ts @@ -0,0 +1,644 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { Video, VideoPrivacy } from '@peertube/peertube-models' +import { cleanupTests, createMultipleServers, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands' +import { expectAccountFollows, expectChannelsFollows } from '@tests/shared/actors.js' +import { testCaptionFile } from '@tests/shared/captions.js' +import { dateIsValid } from '@tests/shared/checks.js' +import { completeVideoCheck } from '@tests/shared/videos.js' + +describe('Test follows', function () { + + describe('Complex follow', function () { + let servers: PeerTubeServer[] = [] + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + }) + + describe('Data propagation after follow', function () { + + it('Should not have followers/followings', async function () { + for (const server of servers) { + const bodies = await Promise.all([ + server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }), + server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' }) + ]) + + for (const body of bodies) { + expect(body.total).to.equal(0) + + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(0) + } + } + }) + + it('Should have server 1 following root account of server 2 and server 3', async function () { + this.timeout(30000) + + await servers[0].follows.follow({ + hosts: [ servers[2].url ], + handles: [ 'root@' + servers[1].host ] + }) + + await waitJobs(servers) + }) + + it('Should have 2 followings on server 1', async function () { + const body = await servers[0].follows.getFollowings({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + let follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(1) + + const body2 = await servers[0].follows.getFollowings({ start: 1, count: 1, sort: 'createdAt' }) + follows = follows.concat(body2.data) + + const server2Follow = follows.find(f => f.following.host === servers[1].host) + const server3Follow = follows.find(f => f.following.host === servers[2].host) + + expect(server2Follow).to.not.be.undefined + expect(server2Follow.following.name).to.equal('root') + expect(server2Follow.state).to.equal('accepted') + + expect(server3Follow).to.not.be.undefined + expect(server3Follow.following.name).to.equal('peertube') + expect(server3Follow.state).to.equal('accepted') + }) + + it('Should have 0 followings on server 2 and 3', async function () { + for (const server of [ servers[1], servers[2] ]) { + const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' }) + expect(body.total).to.equal(0) + + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(0) + } + }) + + it('Should have 1 followers on server 3', async function () { + const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(1) + + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(1) + expect(follows[0].follower.host).to.equal(servers[0].host) + }) + + it('Should have 0 followers on server 1 and 2', async function () { + for (const server of [ servers[0], servers[1] ]) { + const body = await server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }) + expect(body.total).to.equal(0) + + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(0) + } + }) + + it('Should search/filter followings on server 1', async function () { + const sort = 'createdAt' + const start = 0 + const count = 1 + + { + const search = ':' + servers[1].port + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search }) + expect(body.total).to.equal(1) + + const follows = body.data + expect(follows).to.have.lengthOf(1) + expect(follows[0].following.host).to.equal(servers[1].host) + } + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted' }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } + + { + const body = await servers[0].follows.getFollowings({ + start, + count, + sort, + search, + state: 'accepted', + actorType: 'Application' + }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'pending' }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + } + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'root' }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'bla' }) + expect(body.total).to.equal(0) + + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should search/filter followers on server 2', async function () { + const start = 0 + const count = 5 + const sort = 'createdAt' + + { + const search = servers[0].port + '' + + { + const body = await servers[2].follows.getFollowers({ start, count, sort, search }) + expect(body.total).to.equal(1) + + const follows = body.data + expect(follows).to.have.lengthOf(1) + expect(follows[0].following.host).to.equal(servers[2].host) + } + + { + const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted' }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } + + { + const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + + { + const body = await servers[2].follows.getFollowers({ + start, + count, + sort, + search, + state: 'accepted', + actorType: 'Application' + }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } + + { + const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'pending' }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + } + + { + const body = await servers[2].follows.getFollowers({ start, count, sort, search: 'bla' }) + expect(body.total).to.equal(0) + + const follows = body.data + expect(follows).to.have.lengthOf(0) + } + }) + + it('Should have the correct follows counts', async function () { + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 }) + await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) + + // Server 2 and 3 does not know server 1 follow another server (there was not a refresh) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 }) + + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) + }) + + it('Should unfollow server 3 on server 1', async function () { + this.timeout(15000) + + await servers[0].follows.unfollow({ target: servers[2] }) + + await waitJobs(servers) + }) + + it('Should not follow server 3 on server 1 anymore', async function () { + const body = await servers[0].follows.getFollowings({ start: 0, count: 2, sort: 'createdAt' }) + expect(body.total).to.equal(1) + + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(1) + + expect(follows[0].following.host).to.equal(servers[1].host) + }) + + it('Should not have server 1 as follower on server 3 anymore', async function () { + const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(0) + + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(0) + }) + + it('Should have the correct follows counts after the unfollow', async function () { + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 }) + + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 }) + + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 0 }) + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 }) + }) + + it('Should upload a video on server 2 and 3 and propagate only the video of server 2', async function () { + this.timeout(160000) + + await servers[1].videos.upload({ attributes: { name: 'server2' } }) + await servers[2].videos.upload({ attributes: { name: 'server3' } }) + + await waitJobs(servers) + + { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(1) + expect(data[0].name).to.equal('server2') + } + + { + const { total, data } = await servers[1].videos.list() + expect(total).to.equal(1) + expect(data[0].name).to.equal('server2') + } + + { + const { total, data } = await servers[2].videos.list() + expect(total).to.equal(1) + expect(data[0].name).to.equal('server3') + } + }) + + it('Should remove account follow', async function () { + this.timeout(15000) + + await servers[0].follows.unfollow({ target: 'root@' + servers[1].host }) + + await waitJobs(servers) + }) + + it('Should have removed the account follow', async function () { + await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 }) + await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 }) + + { + const { total, data } = await servers[0].follows.getFollowings() + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + + { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + }) + + it('Should follow a channel', async function () { + this.timeout(15000) + + await servers[0].follows.follow({ + handles: [ 'root_channel@' + servers[1].host ] + }) + + await waitJobs(servers) + + await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) + await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) + + { + const { total, data } = await servers[0].follows.getFollowings() + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + } + + { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + } + }) + }) + + describe('Should propagate data on a new server follow', function () { + let video4: Video + + before(async function () { + this.timeout(240000) + + const video4Attributes = { + name: 'server3-4', + category: 2, + nsfw: true, + licence: 6, + tags: [ 'tag1', 'tag2', 'tag3' ] + } + + await servers[2].videos.upload({ attributes: { name: 'server3-2' } }) + await servers[2].videos.upload({ attributes: { name: 'server3-3' } }) + + const video4CreateResult = await servers[2].videos.upload({ attributes: video4Attributes }) + + await servers[2].videos.upload({ attributes: { name: 'server3-5' } }) + await servers[2].videos.upload({ attributes: { name: 'server3-6' } }) + + { + const userAccessToken = await servers[2].users.generateUserAndToken('captain') + + await servers[2].videos.rate({ id: video4CreateResult.id, rating: 'like' }) + await servers[2].videos.rate({ token: userAccessToken, id: video4CreateResult.id, rating: 'dislike' }) + } + + { + await servers[2].comments.createThread({ videoId: video4CreateResult.id, text: 'my super first comment' }) + + await servers[2].comments.addReplyToLastThread({ text: 'my super answer to thread 1' }) + await servers[2].comments.addReplyToLastReply({ text: 'my super answer to answer of thread 1' }) + await servers[2].comments.addReplyToLastThread({ text: 'my second answer to thread 1' }) + } + + { + const { id: threadId } = await servers[2].comments.createThread({ videoId: video4CreateResult.id, text: 'will be deleted' }) + await servers[2].comments.addReplyToLastThread({ text: 'answer to deleted' }) + + const { id: replyId } = await servers[2].comments.addReplyToLastThread({ text: 'will also be deleted' }) + + await servers[2].comments.addReplyToLastReply({ text: 'my second answer to deleted' }) + + await servers[2].comments.delete({ videoId: video4CreateResult.id, commentId: threadId }) + await servers[2].comments.delete({ videoId: video4CreateResult.id, commentId: replyId }) + } + + await servers[2].captions.add({ + language: 'ar', + videoId: video4CreateResult.id, + fixture: 'subtitle-good2.vtt' + }) + + await waitJobs(servers) + + // Server 1 follows server 3 + await servers[0].follows.follow({ hosts: [ servers[2].url ] }) + + await waitJobs(servers) + }) + + it('Should have the correct follows counts', async function () { + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 }) + await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 }) + await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) + + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 }) + await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 }) + await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) + + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) + }) + + it('Should have propagated videos', async function () { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(7) + + const video2 = data.find(v => v.name === 'server3-2') + video4 = data.find(v => v.name === 'server3-4') + const video6 = data.find(v => v.name === 'server3-6') + + expect(video2).to.not.be.undefined + expect(video4).to.not.be.undefined + expect(video6).to.not.be.undefined + + const isLocal = false + const checkAttributes = { + name: 'server3-4', + category: 2, + licence: 6, + language: 'zh', + nsfw: true, + description: 'my super description', + support: 'my super support text', + account: { + name: 'root', + host: servers[2].host + }, + isLocal, + commentsEnabled: true, + downloadEnabled: true, + duration: 5, + tags: [ 'tag1', 'tag2', 'tag3' ], + privacy: VideoPrivacy.PUBLIC, + likes: 1, + dislikes: 1, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal + }, + fixture: 'video_short.webm', + files: [ + { + resolution: 720, + size: 218910 + } + ] + } + await completeVideoCheck({ + server: servers[0], + originServer: servers[2], + videoUUID: video4.uuid, + attributes: checkAttributes + }) + }) + + it('Should have propagated comments', async function () { + const { total, data } = await servers[0].comments.listThreads({ videoId: video4.id, sort: 'createdAt' }) + + expect(total).to.equal(2) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(2) + + { + const comment = data[0] + expect(comment.inReplyToCommentId).to.be.null + expect(comment.text).equal('my super first comment') + expect(comment.videoId).to.equal(video4.id) + expect(comment.id).to.equal(comment.threadId) + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(servers[2].host) + expect(comment.totalReplies).to.equal(3) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + + const threadId = comment.threadId + + const tree = await servers[0].comments.getThread({ videoId: video4.id, threadId }) + expect(tree.comment.text).equal('my super first comment') + expect(tree.children).to.have.lengthOf(2) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.children).to.have.lengthOf(1) + + const childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') + expect(childOfFirstChild.children).to.have.lengthOf(0) + + const secondChild = tree.children[1] + expect(secondChild.comment.text).to.equal('my second answer to thread 1') + expect(secondChild.children).to.have.lengthOf(0) + } + + { + const deletedComment = data[1] + expect(deletedComment).to.not.be.undefined + expect(deletedComment.isDeleted).to.be.true + expect(deletedComment.deletedAt).to.not.be.null + expect(deletedComment.text).to.equal('') + expect(deletedComment.inReplyToCommentId).to.be.null + expect(deletedComment.account).to.be.null + expect(deletedComment.totalReplies).to.equal(2) + expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true + + const tree = await servers[0].comments.getThread({ videoId: video4.id, threadId: deletedComment.threadId }) + const [ commentRoot, deletedChildRoot ] = tree.children + + expect(deletedChildRoot).to.not.be.undefined + expect(deletedChildRoot.comment.isDeleted).to.be.true + expect(deletedChildRoot.comment.deletedAt).to.not.be.null + expect(deletedChildRoot.comment.text).to.equal('') + expect(deletedChildRoot.comment.inReplyToCommentId).to.equal(deletedComment.id) + expect(deletedChildRoot.comment.account).to.be.null + expect(deletedChildRoot.children).to.have.lengthOf(1) + + const answerToDeletedChild = deletedChildRoot.children[0] + expect(answerToDeletedChild.comment).to.not.be.undefined + expect(answerToDeletedChild.comment.inReplyToCommentId).to.equal(deletedChildRoot.comment.id) + expect(answerToDeletedChild.comment.text).to.equal('my second answer to deleted') + expect(answerToDeletedChild.comment.account.name).to.equal('root') + + expect(commentRoot.comment).to.not.be.undefined + expect(commentRoot.comment.inReplyToCommentId).to.equal(deletedComment.id) + expect(commentRoot.comment.text).to.equal('answer to deleted') + expect(commentRoot.comment.account.name).to.equal('root') + } + }) + + it('Should have propagated captions', async function () { + const body = await servers[0].captions.list({ videoId: video4.id }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const caption1 = body.data[0] + expect(caption1.language.id).to.equal('ar') + expect(caption1.language.label).to.equal('Arabic') + expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/.+-ar.vtt$')) + await testCaptionFile(servers[0].url, caption1.captionPath, 'Subtitle good 2.') + }) + + it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () { + this.timeout(5000) + + await servers[0].follows.unfollow({ target: servers[2] }) + + await waitJobs(servers) + + const { total } = await servers[0].videos.list() + expect(total).to.equal(1) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) + }) + + describe('Simple data propagation propagate data on a new channel follow', function () { + let servers: PeerTubeServer[] = [] + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + await setAccessTokensToServers(servers) + + await servers[0].videos.upload({ attributes: { name: 'video to add' } }) + + await waitJobs(servers) + + for (const server of [ servers[1], servers[2] ]) { + const video = await server.videos.find({ name: 'video to add' }) + expect(video).to.not.exist + } + }) + + it('Should have propagated video after new channel follow', async function () { + this.timeout(60000) + + await servers[1].follows.follow({ handles: [ 'root_channel@' + servers[0].host ] }) + + await waitJobs(servers) + + const video = await servers[1].videos.find({ name: 'video to add' }) + expect(video).to.exist + }) + + it('Should have propagated video after new account follow', async function () { + this.timeout(60000) + + await servers[2].follows.follow({ handles: [ 'root@' + servers[0].host ] }) + + await waitJobs(servers) + + const video = await servers[2].videos.find({ name: 'video to add' }) + expect(video).to.exist + }) + + after(async function () { + await cleanupTests(servers) + }) + }) +}) diff --git a/packages/tests/src/api/server/handle-down.ts b/packages/tests/src/api/server/handle-down.ts new file mode 100644 index 000000000..604df129f --- /dev/null +++ b/packages/tests/src/api/server/handle-down.ts @@ -0,0 +1,339 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, JobState, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + CommentsCommand, + createMultipleServers, + killallServers, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { completeVideoCheck } from '@tests/shared/videos.js' + +describe('Test handle downs', function () { + let servers: PeerTubeServer[] = [] + let sqlCommands: SQLCommand[] = [] + + let threadIdServer1: number + let threadIdServer2: number + let commentIdServer1: number + let commentIdServer2: number + let missedVideo1: VideoCreateResult + let missedVideo2: VideoCreateResult + let unlistedVideo: VideoCreateResult + + const videoIdsServer1: string[] = [] + + const videoAttributes = { + name: 'my super name for server 1', + category: 5, + licence: 4, + language: 'ja', + nsfw: true, + privacy: VideoPrivacy.PUBLIC, + description: 'my super description for server 1', + support: 'my super support text for server 1', + tags: [ 'tag1p1', 'tag2p1' ], + fixture: 'video_short1.webm' + } + + const unlistedVideoAttributes = { ...videoAttributes, privacy: VideoPrivacy.UNLISTED } + + let checkAttributes: any + let unlistedCheckAttributes: any + + let commentCommands: CommentsCommand[] + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + commentCommands = servers.map(s => s.comments) + + checkAttributes = { + name: 'my super name for server 1', + category: 5, + licence: 4, + language: 'ja', + nsfw: true, + description: 'my super description for server 1', + support: 'my super support text for server 1', + account: { + name: 'root', + host: servers[0].host + }, + isLocal: false, + duration: 10, + tags: [ 'tag1p1', 'tag2p1' ], + privacy: VideoPrivacy.PUBLIC, + commentsEnabled: true, + downloadEnabled: true, + channel: { + name: 'root_channel', + displayName: 'Main root channel', + description: '', + isLocal: false + }, + fixture: 'video_short1.webm', + files: [ + { + resolution: 720, + size: 572456 + } + ] + } + unlistedCheckAttributes = { ...checkAttributes, privacy: VideoPrivacy.UNLISTED } + + // Get the access tokens + await setAccessTokensToServers(servers) + + sqlCommands = servers.map(s => new SQLCommand(s)) + }) + + it('Should remove followers that are often down', async function () { + this.timeout(240000) + + // Server 2 and 3 follow server 1 + await servers[1].follows.follow({ hosts: [ servers[0].url ] }) + await servers[2].follows.follow({ hosts: [ servers[0].url ] }) + + await waitJobs(servers) + + // Upload a video to server 1 + await servers[0].videos.upload({ attributes: videoAttributes }) + + await waitJobs(servers) + + // And check all servers have this video + for (const server of servers) { + const { data } = await server.videos.list() + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(1) + } + + // Kill server 2 + await killallServers([ servers[1] ]) + + // Remove server 2 follower + for (let i = 0; i < 10; i++) { + await servers[0].videos.upload({ attributes: videoAttributes }) + } + + await waitJobs([ servers[0], servers[2] ]) + + // Kill server 3 + await killallServers([ servers[2] ]) + + missedVideo1 = await servers[0].videos.upload({ attributes: videoAttributes }) + + missedVideo2 = await servers[0].videos.upload({ attributes: videoAttributes }) + + // Unlisted video + unlistedVideo = await servers[0].videos.upload({ attributes: unlistedVideoAttributes }) + + // Add comments to video 2 + { + const text = 'thread 1' + let comment = await commentCommands[0].createThread({ videoId: missedVideo2.uuid, text }) + threadIdServer1 = comment.id + + comment = await commentCommands[0].addReply({ videoId: missedVideo2.uuid, toCommentId: comment.id, text: 'comment 1-1' }) + + const created = await commentCommands[0].addReply({ videoId: missedVideo2.uuid, toCommentId: comment.id, text: 'comment 1-2' }) + commentIdServer1 = created.id + } + + await waitJobs(servers[0]) + // Wait scheduler + await wait(11000) + + // Only server 3 is still a follower of server 1 + const body = await servers[0].follows.getFollowers({ start: 0, count: 2, sort: 'createdAt' }) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].follower.host).to.equal(servers[2].host) + }) + + it('Should not have pending/processing jobs anymore', async function () { + const states: JobState[] = [ 'waiting', 'active' ] + + for (const state of states) { + const body = await servers[0].jobs.list({ + state, + start: 0, + count: 50, + sort: '-createdAt' + }) + expect(body.data).to.have.length(0) + } + }) + + it('Should re-follow server 1', async function () { + this.timeout(70000) + + await servers[1].run() + await servers[2].run() + + await servers[1].follows.unfollow({ target: servers[0] }) + await waitJobs(servers) + + await servers[1].follows.follow({ hosts: [ servers[0].url ] }) + + await waitJobs(servers) + + const body = await servers[0].follows.getFollowers({ start: 0, count: 2, sort: 'createdAt' }) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + }) + + it('Should send an update to server 3, and automatically fetch the video', async function () { + this.timeout(15000) + + { + const { data } = await servers[2].videos.list() + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(11) + } + + await servers[0].videos.update({ id: missedVideo1.uuid }) + await servers[0].videos.update({ id: unlistedVideo.uuid }) + + await waitJobs(servers) + + { + const { data } = await servers[2].videos.list() + expect(data).to.be.an('array') + // 1 video is unlisted + expect(data).to.have.lengthOf(12) + } + + // Check unlisted video + const video = await servers[2].videos.get({ id: unlistedVideo.uuid }) + await completeVideoCheck({ server: servers[2], originServer: servers[0], videoUUID: video.uuid, attributes: unlistedCheckAttributes }) + }) + + it('Should send comments on a video to server 3, and automatically fetch the video', async function () { + this.timeout(25000) + + await commentCommands[0].addReply({ videoId: missedVideo2.uuid, toCommentId: commentIdServer1, text: 'comment 1-3' }) + + await waitJobs(servers) + + await servers[2].videos.get({ id: missedVideo2.uuid }) + + { + const { data } = await servers[2].comments.listThreads({ videoId: missedVideo2.uuid }) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(1) + + threadIdServer2 = data[0].id + + const tree = await servers[2].comments.getThread({ videoId: missedVideo2.uuid, threadId: threadIdServer2 }) + expect(tree.comment.text).equal('thread 1') + expect(tree.children).to.have.lengthOf(1) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('comment 1-1') + expect(firstChild.children).to.have.lengthOf(1) + + const childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('comment 1-2') + expect(childOfFirstChild.children).to.have.lengthOf(1) + + const childOfChildFirstChild = childOfFirstChild.children[0] + expect(childOfChildFirstChild.comment.text).to.equal('comment 1-3') + expect(childOfChildFirstChild.children).to.have.lengthOf(0) + + commentIdServer2 = childOfChildFirstChild.comment.id + } + }) + + it('Should correctly reply to the comment', async function () { + this.timeout(15000) + + await servers[2].comments.addReply({ videoId: missedVideo2.uuid, toCommentId: commentIdServer2, text: 'comment 1-4' }) + + await waitJobs(servers) + + const tree = await commentCommands[0].getThread({ videoId: missedVideo2.uuid, threadId: threadIdServer1 }) + + expect(tree.comment.text).equal('thread 1') + expect(tree.children).to.have.lengthOf(1) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('comment 1-1') + expect(firstChild.children).to.have.lengthOf(1) + + const childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('comment 1-2') + expect(childOfFirstChild.children).to.have.lengthOf(1) + + const childOfChildFirstChild = childOfFirstChild.children[0] + expect(childOfChildFirstChild.comment.text).to.equal('comment 1-3') + expect(childOfChildFirstChild.children).to.have.lengthOf(1) + + const childOfChildOfChildOfFirstChild = childOfChildFirstChild.children[0] + expect(childOfChildOfChildOfFirstChild.comment.text).to.equal('comment 1-4') + expect(childOfChildOfChildOfFirstChild.children).to.have.lengthOf(0) + }) + + it('Should upload many videos on server 1', async function () { + this.timeout(240000) + + for (let i = 0; i < 10; i++) { + const uuid = (await servers[0].videos.quickUpload({ name: 'video ' + i })).uuid + videoIdsServer1.push(uuid) + } + + await waitJobs(servers) + + for (const id of videoIdsServer1) { + await servers[1].videos.get({ id }) + } + + await waitJobs(servers) + await sqlCommands[1].setActorFollowScores(20) + + // Wait video expiration + await wait(11000) + + // Refresh video -> score + 10 = 30 + await servers[1].videos.get({ id: videoIdsServer1[0] }) + + await waitJobs(servers) + }) + + it('Should remove followings that are down', async function () { + this.timeout(120000) + + await killallServers([ servers[0] ]) + + // Wait video expiration + await wait(11000) + + for (let i = 0; i < 5; i++) { + try { + await servers[1].videos.get({ id: videoIdsServer1[i] }) + await waitJobs([ servers[1] ]) + await wait(1500) + } catch {} + } + + for (const id of videoIdsServer1) { + await servers[1].videos.get({ id, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + }) + + after(async function () { + for (const sqlCommand of sqlCommands) { + await sqlCommand.cleanup() + } + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/server/homepage.ts b/packages/tests/src/api/server/homepage.ts new file mode 100644 index 000000000..082a2fb91 --- /dev/null +++ b/packages/tests/src/api/server/homepage.ts @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + CustomPagesCommand, + killallServers, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar +} from '@peertube/peertube-server-commands' + +async function getHomepageState (server: PeerTubeServer) { + const config = await server.config.getConfig() + + return config.homepage.enabled +} + +describe('Test instance homepage actions', function () { + let server: PeerTubeServer + let command: CustomPagesCommand + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(server) + + command = server.customPage + }) + + it('Should not have a homepage', async function () { + const state = await getHomepageState(server) + expect(state).to.be.false + + await command.getInstanceHomepage({ expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should set a homepage', async function () { + await command.updateInstanceHomepage({ content: '' }) + + const page = await command.getInstanceHomepage() + expect(page.content).to.equal('') + + const state = await getHomepageState(server) + expect(state).to.be.true + }) + + it('Should have the same homepage after a restart', async function () { + this.timeout(30000) + + await killallServers([ server ]) + + await server.run() + + const page = await command.getInstanceHomepage() + expect(page.content).to.equal('') + + const state = await getHomepageState(server) + expect(state).to.be.true + }) + + it('Should empty the homepage', async function () { + await command.updateInstanceHomepage({ content: '' }) + + const page = await command.getInstanceHomepage() + expect(page.content).to.be.empty + + const state = await getHomepageState(server) + expect(state).to.be.false + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/index.ts b/packages/tests/src/api/server/index.ts new file mode 100644 index 000000000..5c80a5a37 --- /dev/null +++ b/packages/tests/src/api/server/index.ts @@ -0,0 +1,22 @@ +import './auto-follows.js' +import './bulk.js' +import './config-defaults.js' +import './config.js' +import './contact-form.js' +import './email.js' +import './follow-constraints.js' +import './follows.js' +import './follows-moderation.js' +import './homepage.js' +import './handle-down.js' +import './jobs.js' +import './logs.js' +import './reverse-proxy.js' +import './services.js' +import './slow-follows.js' +import './stats.js' +import './tracker.js' +import './no-client.js' +import './open-telemetry.js' +import './plugins.js' +import './proxy.js' diff --git a/packages/tests/src/api/server/jobs.ts b/packages/tests/src/api/server/jobs.ts new file mode 100644 index 000000000..3d60b1431 --- /dev/null +++ b/packages/tests/src/api/server/jobs.ts @@ -0,0 +1,128 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { dateIsValid } from '@tests/shared/checks.js' +import { wait } from '@peertube/peertube-core-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test jobs', function () { + let servers: PeerTubeServer[] + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + it('Should create some jobs', async function () { + this.timeout(240000) + + await servers[1].videos.upload({ attributes: { name: 'video1' } }) + await servers[1].videos.upload({ attributes: { name: 'video2' } }) + + await waitJobs(servers) + }) + + it('Should list jobs', async function () { + const body = await servers[1].jobs.list({ state: 'completed' }) + expect(body.total).to.be.above(2) + expect(body.data).to.have.length.above(2) + }) + + it('Should list jobs with sort, pagination and job type', async function () { + { + const body = await servers[1].jobs.list({ + state: 'completed', + start: 1, + count: 2, + sort: 'createdAt' + }) + expect(body.total).to.be.above(2) + expect(body.data).to.have.lengthOf(2) + + let job = body.data[0] + // Skip repeat jobs + if (job.type === 'videos-views-stats') job = body.data[1] + + expect(job.state).to.equal('completed') + expect(dateIsValid(job.createdAt as string)).to.be.true + expect(dateIsValid(job.processedOn as string)).to.be.true + expect(dateIsValid(job.finishedOn as string)).to.be.true + } + + { + const body = await servers[1].jobs.list({ + state: 'completed', + start: 0, + count: 100, + sort: 'createdAt', + jobType: 'activitypub-http-broadcast' + }) + expect(body.total).to.be.above(2) + + for (const j of body.data) { + expect(j.type).to.equal('activitypub-http-broadcast') + } + } + }) + + it('Should list all jobs', async function () { + const body = await servers[1].jobs.list() + expect(body.total).to.be.above(2) + + const jobs = body.data + expect(jobs).to.have.length.above(2) + + expect(jobs.find(j => j.state === 'completed')).to.not.be.undefined + }) + + it('Should pause the job queue', async function () { + this.timeout(120000) + + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'video2' } }) + await waitJobs(servers) + + await servers[1].jobs.pauseJobQueue() + await servers[1].videos.runTranscoding({ videoId: uuid, transcodingType: 'hls' }) + + await wait(5000) + + { + const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' }) + // waiting includes waiting-children + expect(body.data).to.have.lengthOf(4) + } + + { + const body = await servers[1].jobs.list({ state: 'waiting-children', jobType: 'video-transcoding' }) + expect(body.data).to.have.lengthOf(1) + } + }) + + it('Should resume the job queue', async function () { + this.timeout(120000) + + await servers[1].jobs.resumeJobQueue() + + await waitJobs(servers) + + const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' }) + expect(body.data).to.have.lengthOf(0) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/server/logs.ts b/packages/tests/src/api/server/logs.ts new file mode 100644 index 000000000..11c86d694 --- /dev/null +++ b/packages/tests/src/api/server/logs.ts @@ -0,0 +1,265 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + killallServers, + LogsCommand, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test logs', function () { + let server: PeerTubeServer + let logsCommand: LogsCommand + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + logsCommand = server.logs + }) + + describe('With the standard log file', function () { + + it('Should get logs with a start date', async function () { + this.timeout(60000) + + await server.videos.upload({ attributes: { name: 'video 1' } }) + await waitJobs([ server ]) + + const now = new Date() + + await server.videos.upload({ attributes: { name: 'video 2' } }) + await waitJobs([ server ]) + + const body = await logsCommand.getLogs({ startDate: now }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('Video with name video 1')).to.be.false + expect(logsString.includes('Video with name video 2')).to.be.true + }) + + it('Should get logs with an end date', async function () { + this.timeout(60000) + + await server.videos.upload({ attributes: { name: 'video 3' } }) + await waitJobs([ server ]) + + const now1 = new Date() + + await server.videos.upload({ attributes: { name: 'video 4' } }) + await waitJobs([ server ]) + + const now2 = new Date() + + await server.videos.upload({ attributes: { name: 'video 5' } }) + await waitJobs([ server ]) + + const body = await logsCommand.getLogs({ startDate: now1, endDate: now2 }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('Video with name video 3')).to.be.false + expect(logsString.includes('Video with name video 4')).to.be.true + expect(logsString.includes('Video with name video 5')).to.be.false + }) + + it('Should filter by level', async function () { + this.timeout(60000) + + const now = new Date() + + await server.videos.upload({ attributes: { name: 'video 6' } }) + await waitJobs([ server ]) + + { + const body = await logsCommand.getLogs({ startDate: now, level: 'info' }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('Video with name video 6')).to.be.true + } + + { + const body = await logsCommand.getLogs({ startDate: now, level: 'warn' }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('Video with name video 6')).to.be.false + } + }) + + it('Should filter by tag', async function () { + const now = new Date() + + const { uuid } = await server.videos.upload({ attributes: { name: 'video 6' } }) + await waitJobs([ server ]) + + { + const body = await logsCommand.getLogs({ startDate: now, level: 'debug', tagsOneOf: [ 'toto' ] }) + expect(body).to.have.lengthOf(0) + } + + { + const body = await logsCommand.getLogs({ startDate: now, level: 'debug', tagsOneOf: [ uuid ] }) + expect(body).to.not.have.lengthOf(0) + + for (const line of body) { + expect(line.tags).to.contain(uuid) + } + } + }) + + it('Should log ping requests', async function () { + const now = new Date() + + await server.servers.ping() + + const body = await logsCommand.getLogs({ startDate: now, level: 'info' }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('/api/v1/ping')).to.be.true + }) + + it('Should not log ping requests', async function () { + this.timeout(60000) + + await killallServers([ server ]) + + await server.run({ log: { log_ping_requests: false } }) + + const now = new Date() + + await server.servers.ping() + + const body = await logsCommand.getLogs({ startDate: now, level: 'info' }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('/api/v1/ping')).to.be.false + }) + }) + + describe('With the audit log', function () { + + it('Should get logs with a start date', async function () { + this.timeout(60000) + + await server.videos.upload({ attributes: { name: 'video 7' } }) + await waitJobs([ server ]) + + const now = new Date() + + await server.videos.upload({ attributes: { name: 'video 8' } }) + await waitJobs([ server ]) + + const body = await logsCommand.getAuditLogs({ startDate: now }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('video 7')).to.be.false + expect(logsString.includes('video 8')).to.be.true + + expect(body).to.have.lengthOf(1) + + const item = body[0] + + const message = JSON.parse(item.message) + expect(message.domain).to.equal('videos') + expect(message.action).to.equal('create') + }) + + it('Should get logs with an end date', async function () { + this.timeout(60000) + + await server.videos.upload({ attributes: { name: 'video 9' } }) + await waitJobs([ server ]) + + const now1 = new Date() + + await server.videos.upload({ attributes: { name: 'video 10' } }) + await waitJobs([ server ]) + + const now2 = new Date() + + await server.videos.upload({ attributes: { name: 'video 11' } }) + await waitJobs([ server ]) + + const body = await logsCommand.getAuditLogs({ startDate: now1, endDate: now2 }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('video 9')).to.be.false + expect(logsString.includes('video 10')).to.be.true + expect(logsString.includes('video 11')).to.be.false + }) + }) + + describe('When creating log from the client', function () { + + it('Should create a warn client log', async function () { + const now = new Date() + + await server.logs.createLogClient({ + payload: { + level: 'warn', + url: 'http://example.com', + message: 'my super client message' + }, + token: null + }) + + const body = await logsCommand.getLogs({ startDate: now }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('my super client message')).to.be.true + }) + + it('Should create an error authenticated client log', async function () { + const now = new Date() + + await server.logs.createLogClient({ + payload: { + url: 'https://example.com/page1', + level: 'error', + message: 'my super client message 2', + userAgent: 'super user agent', + meta: '{hello}', + stackTrace: 'super stack trace' + } + }) + + const body = await logsCommand.getLogs({ startDate: now }) + const logsString = JSON.stringify(body) + + expect(logsString.includes('my super client message 2')).to.be.true + expect(logsString.includes('super user agent')).to.be.true + expect(logsString.includes('super stack trace')).to.be.true + expect(logsString.includes('{hello}')).to.be.true + expect(logsString.includes('https://example.com/page1')).to.be.true + }) + + it('Should refuse to create client logs', async function () { + await server.kill() + + await server.run({ + log: { + accept_client_log: false + } + }) + + await server.logs.createLogClient({ + payload: { + level: 'warn', + url: 'http://example.com', + message: 'my super client message' + }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/no-client.ts b/packages/tests/src/api/server/no-client.ts new file mode 100644 index 000000000..0f097d50b --- /dev/null +++ b/packages/tests/src/api/server/no-client.ts @@ -0,0 +1,24 @@ +import request from 'supertest' +import { HttpStatusCode } from '@peertube/peertube-models' +import { cleanupTests, createSingleServer, PeerTubeServer } from '@peertube/peertube-server-commands' + +describe('Start and stop server without web client routes', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, {}, { peertubeArgs: [ '--no-client' ] }) + }) + + it('Should fail getting the client', function () { + const req = request(server.url) + .get('/') + + return req.expect(HttpStatusCode.NOT_FOUND_404) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/open-telemetry.ts b/packages/tests/src/api/server/open-telemetry.ts new file mode 100644 index 000000000..8ed3801db --- /dev/null +++ b/packages/tests/src/api/server/open-telemetry.ts @@ -0,0 +1,193 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode, PlaybackMetricCreate, VideoPrivacy, VideoResolution } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeRawRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { expectLogDoesNotContain, expectLogContain } from '@tests/shared/checks.js' +import { MockHTTP } from '@tests/shared/mock-servers/mock-http.js' + +describe('Open Telemetry', function () { + let server: PeerTubeServer + + describe('Metrics', function () { + const metricsUrl = 'http://127.0.0.1:9092/metrics' + + it('Should not enable open telemetry metrics', async function () { + this.timeout(60000) + + server = await createSingleServer(1) + + let hasError = false + try { + await makeRawRequest({ url: metricsUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } catch (err) { + hasError = err.message.includes('ECONNREFUSED') + } + + expect(hasError).to.be.true + + await server.kill() + }) + + it('Should enable open telemetry metrics', async function () { + this.timeout(120000) + + await server.run({ + open_telemetry: { + metrics: { + enabled: true + } + } + }) + + // Simulate a HTTP request + await server.videos.list() + + const res = await makeRawRequest({ url: metricsUrl, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.text).to.contain('peertube_job_queue_total{') + expect(res.text).to.contain('http_request_duration_ms_bucket{') + }) + + it('Should have playback metrics', async function () { + await setAccessTokensToServers([ server ]) + + const video = await server.videos.quickUpload({ name: 'video' }) + + await server.metrics.addPlaybackMetric({ + metrics: { + playerMode: 'p2p-media-loader', + resolution: VideoResolution.H_1080P, + fps: 30, + resolutionChanges: 1, + errors: 2, + downloadedBytesP2P: 0, + downloadedBytesHTTP: 0, + uploadedBytesP2P: 5, + p2pPeers: 1, + p2pEnabled: false, + videoId: video.uuid + } + }) + + const res = await makeRawRequest({ url: metricsUrl, expectedStatus: HttpStatusCode.OK_200 }) + + expect(res.text).to.contain('peertube_playback_http_downloaded_bytes_total{') + expect(res.text).to.contain('peertube_playback_p2p_peers{') + expect(res.text).to.contain('p2pEnabled="false"') + }) + + it('Should take the last playback metric', async function () { + await setAccessTokensToServers([ server ]) + + const video = await server.videos.quickUpload({ name: 'video' }) + + const metrics = { + playerMode: 'p2p-media-loader', + resolution: VideoResolution.H_1080P, + fps: 30, + resolutionChanges: 1, + errors: 2, + downloadedBytesP2P: 0, + downloadedBytesHTTP: 0, + uploadedBytesP2P: 5, + p2pPeers: 7, + p2pEnabled: false, + videoId: video.uuid + } as PlaybackMetricCreate + + await server.metrics.addPlaybackMetric({ metrics }) + + metrics.p2pPeers = 42 + await server.metrics.addPlaybackMetric({ metrics }) + + const res = await makeRawRequest({ url: metricsUrl, expectedStatus: HttpStatusCode.OK_200 }) + + // eslint-disable-next-line max-len + const label = `{videoOrigin="local",playerMode="p2p-media-loader",resolution="1080",fps="30",p2pEnabled="false",videoUUID="${video.uuid}"}` + expect(res.text).to.contain(`peertube_playback_p2p_peers${label} 42`) + expect(res.text).to.not.contain(`peertube_playback_p2p_peers${label} 7`) + }) + + it('Should disable http request duration metrics', async function () { + await server.kill() + + await server.run({ + open_telemetry: { + metrics: { + enabled: true, + http_request_duration: { + enabled: false + } + } + } + }) + + // Simulate a HTTP request + await server.videos.list() + + const res = await makeRawRequest({ url: metricsUrl, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.text).to.not.contain('http_request_duration_ms_bucket{') + }) + + after(async function () { + await server.kill() + }) + }) + + describe('Tracing', function () { + let mockHTTP: MockHTTP + let mockPort: number + + before(async function () { + mockHTTP = new MockHTTP() + mockPort = await mockHTTP.initialize() + }) + + it('Should enable open telemetry tracing', async function () { + server = await createSingleServer(1) + + await expectLogDoesNotContain(server, 'Registering Open Telemetry tracing') + + await server.kill() + }) + + it('Should enable open telemetry metrics', async function () { + await server.run({ + open_telemetry: { + tracing: { + enabled: true, + jaeger_exporter: { + endpoint: 'http://127.0.0.1:' + mockPort + } + } + } + }) + + await expectLogContain(server, 'Registering Open Telemetry tracing') + }) + + it('Should upload a video and correctly works', async function () { + await setAccessTokensToServers([ server ]) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PUBLIC }) + + const video = await server.videos.get({ id: uuid }) + + expect(video.name).to.equal('video') + }) + + after(async function () { + await mockHTTP.terminate() + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/plugins.ts b/packages/tests/src/api/server/plugins.ts new file mode 100644 index 000000000..a78cea025 --- /dev/null +++ b/packages/tests/src/api/server/plugins.ts @@ -0,0 +1,410 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists, remove } from 'fs-extra/esm' +import { join } from 'path' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, PluginType } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + killallServers, + makeGetRequest, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { testHelloWorldRegisteredSettings } from '@tests/shared/plugins.js' + +describe('Test plugins', function () { + let server: PeerTubeServer + let sqlCommand: SQLCommand + let command: PluginsCommand + + before(async function () { + this.timeout(30000) + + const configOverride = { + plugins: { + index: { check_latest_versions_interval: '5 seconds' } + } + } + server = await createSingleServer(1, configOverride) + await setAccessTokensToServers([ server ]) + + command = server.plugins + + sqlCommand = new SQLCommand(server) + }) + + it('Should list and search available plugins and themes', async function () { + this.timeout(30000) + + { + const body = await command.listAvailable({ + count: 1, + start: 0, + pluginType: PluginType.THEME, + search: 'background-red' + }) + + expect(body.total).to.be.at.least(1) + expect(body.data).to.have.lengthOf(1) + } + + { + const body1 = await command.listAvailable({ + count: 2, + start: 0, + sort: 'npmName' + }) + expect(body1.total).to.be.at.least(2) + + const data1 = body1.data + expect(data1).to.have.lengthOf(2) + + const body2 = await command.listAvailable({ + count: 2, + start: 0, + sort: '-npmName' + }) + expect(body2.total).to.be.at.least(2) + + const data2 = body2.data + expect(data2).to.have.lengthOf(2) + + expect(data1[0].npmName).to.not.equal(data2[0].npmName) + } + + { + const body = await command.listAvailable({ + count: 10, + start: 0, + pluginType: PluginType.THEME, + search: 'background-red', + currentPeerTubeEngine: '1.0.0' + }) + + const p = body.data.find(p => p.npmName === 'peertube-theme-background-red') + expect(p).to.be.undefined + } + }) + + it('Should install a plugin and a theme', async function () { + this.timeout(30000) + + await command.install({ npmName: 'peertube-plugin-hello-world' }) + await command.install({ npmName: 'peertube-theme-background-red' }) + }) + + it('Should have the plugin loaded in the configuration', async function () { + for (const config of [ await server.config.getConfig(), await server.config.getIndexHTMLConfig() ]) { + const theme = config.theme.registered.find(r => r.name === 'background-red') + expect(theme).to.not.be.undefined + expect(theme.npmName).to.equal('peertube-theme-background-red') + + const plugin = config.plugin.registered.find(r => r.name === 'hello-world') + expect(plugin).to.not.be.undefined + expect(plugin.npmName).to.equal('peertube-plugin-hello-world') + } + }) + + it('Should update the default theme in the configuration', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + theme: { default: 'background-red' } + } + }) + + for (const config of [ await server.config.getConfig(), await server.config.getIndexHTMLConfig() ]) { + expect(config.theme.default).to.equal('background-red') + } + }) + + it('Should update my default theme', async function () { + await server.users.updateMe({ theme: 'background-red' }) + + const user = await server.users.getMyInfo() + expect(user.theme).to.equal('background-red') + }) + + it('Should list plugins and themes', async function () { + { + const body = await command.list({ + count: 1, + start: 0, + pluginType: PluginType.THEME + }) + expect(body.total).to.be.at.least(1) + + const data = body.data + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('background-red') + } + + { + const { data } = await command.list({ + count: 2, + start: 0, + sort: 'name' + }) + + expect(data[0].name).to.equal('background-red') + expect(data[1].name).to.equal('hello-world') + } + + { + const body = await command.list({ + count: 2, + start: 1, + sort: 'name' + }) + + expect(body.data[0].name).to.equal('hello-world') + } + }) + + it('Should get registered settings', async function () { + await testHelloWorldRegisteredSettings(server) + }) + + it('Should get public settings', async function () { + const body = await command.getPublicSettings({ npmName: 'peertube-plugin-hello-world' }) + const publicSettings = body.publicSettings + + expect(Object.keys(publicSettings)).to.have.lengthOf(1) + expect(Object.keys(publicSettings)).to.deep.equal([ 'user-name' ]) + expect(publicSettings['user-name']).to.be.null + }) + + it('Should update the settings', async function () { + const settings = { + 'admin-name': 'Cid' + } + + await command.updateSettings({ + npmName: 'peertube-plugin-hello-world', + settings + }) + }) + + it('Should have watched settings changes', async function () { + await server.servers.waitUntilLog('Settings changed!') + }) + + it('Should get a plugin and a theme', async function () { + { + const plugin = await command.get({ npmName: 'peertube-plugin-hello-world' }) + + expect(plugin.type).to.equal(PluginType.PLUGIN) + expect(plugin.name).to.equal('hello-world') + expect(plugin.description).to.exist + expect(plugin.homepage).to.exist + expect(plugin.uninstalled).to.be.false + expect(plugin.enabled).to.be.true + expect(plugin.description).to.exist + expect(plugin.version).to.exist + expect(plugin.peertubeEngine).to.exist + expect(plugin.createdAt).to.exist + + expect(plugin.settings).to.not.be.undefined + expect(plugin.settings['admin-name']).to.equal('Cid') + } + + { + const plugin = await command.get({ npmName: 'peertube-theme-background-red' }) + + expect(plugin.type).to.equal(PluginType.THEME) + expect(plugin.name).to.equal('background-red') + expect(plugin.description).to.exist + expect(plugin.homepage).to.exist + expect(plugin.uninstalled).to.be.false + expect(plugin.enabled).to.be.true + expect(plugin.description).to.exist + expect(plugin.version).to.exist + expect(plugin.peertubeEngine).to.exist + expect(plugin.createdAt).to.exist + + expect(plugin.settings).to.be.null + } + }) + + it('Should update the plugin and the theme', async function () { + this.timeout(180000) + + // Wait the scheduler that get the latest plugins versions + await wait(6000) + + async function testUpdate (type: 'plugin' | 'theme', name: string) { + // Fake update our plugin version + await sqlCommand.setPluginVersion(name, '0.0.1') + + // Fake update package.json + const packageJSON = await command.getPackageJSON(`peertube-${type}-${name}`) + const oldVersion = packageJSON.version + + packageJSON.version = '0.0.1' + await command.updatePackageJSON(`peertube-${type}-${name}`, packageJSON) + + // Restart the server to take into account this change + await killallServers([ server ]) + await server.run() + + const checkConfig = async (version: string) => { + for (const config of [ await server.config.getConfig(), await server.config.getIndexHTMLConfig() ]) { + expect(config[type].registered.find(r => r.name === name).version).to.equal(version) + } + } + + const getPluginFromAPI = async () => { + const body = await command.list({ pluginType: type === 'plugin' ? PluginType.PLUGIN : PluginType.THEME }) + + return body.data.find(p => p.name === name) + } + + { + const plugin = await getPluginFromAPI() + expect(plugin.version).to.equal('0.0.1') + expect(plugin.latestVersion).to.exist + expect(plugin.latestVersion).to.not.equal('0.0.1') + + await checkConfig('0.0.1') + } + + { + await command.update({ npmName: `peertube-${type}-${name}` }) + + const plugin = await getPluginFromAPI() + expect(plugin.version).to.equal(oldVersion) + + const updatedPackageJSON = await command.getPackageJSON(`peertube-${type}-${name}`) + expect(updatedPackageJSON.version).to.equal(oldVersion) + + await checkConfig(oldVersion) + } + } + + await testUpdate('theme', 'background-red') + await testUpdate('plugin', 'hello-world') + }) + + it('Should uninstall the plugin', async function () { + await command.uninstall({ npmName: 'peertube-plugin-hello-world' }) + + const body = await command.list({ pluginType: PluginType.PLUGIN }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should list uninstalled plugins', async function () { + const body = await command.list({ pluginType: PluginType.PLUGIN, uninstalled: true }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const plugin = body.data[0] + expect(plugin.name).to.equal('hello-world') + expect(plugin.enabled).to.be.false + expect(plugin.uninstalled).to.be.true + }) + + it('Should uninstall the theme', async function () { + await command.uninstall({ npmName: 'peertube-theme-background-red' }) + }) + + it('Should have updated the configuration', async function () { + for (const config of [ await server.config.getConfig(), await server.config.getIndexHTMLConfig() ]) { + expect(config.theme.default).to.equal('default') + + const theme = config.theme.registered.find(r => r.name === 'background-red') + expect(theme).to.be.undefined + + const plugin = config.plugin.registered.find(r => r.name === 'hello-world') + expect(plugin).to.be.undefined + } + }) + + it('Should have updated the user theme', async function () { + const user = await server.users.getMyInfo() + expect(user.theme).to.equal('instance-default') + }) + + it('Should not install a broken plugin', async function () { + this.timeout(60000) + + async function check () { + const body = await command.list({ pluginType: PluginType.PLUGIN }) + const plugins = body.data + expect(plugins.find(p => p.name === 'test-broken')).to.not.exist + } + + await command.install({ + path: PluginsCommand.getPluginTestPath('-broken'), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await check() + + await killallServers([ server ]) + await server.run() + + await check() + }) + + it('Should rebuild native modules on Node ABI change', async function () { + this.timeout(60000) + + const removeNativeModule = async () => { + await remove(join(baseNativeModule, 'build')) + await remove(join(baseNativeModule, 'prebuilds')) + } + + await command.install({ path: PluginsCommand.getPluginTestPath('-native') }) + + await makeGetRequest({ + url: server.url, + path: '/plugins/test-native/router', + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + + const query = `UPDATE "application" SET "nodeABIVersion" = 1` + await sqlCommand.updateQuery(query) + + const baseNativeModule = server.servers.buildDirectory(join('plugins', 'node_modules', 'a-native-example')) + + await removeNativeModule() + await server.kill() + await server.run() + + await wait(3000) + + expect(await pathExists(join(baseNativeModule, 'build'))).to.be.true + expect(await pathExists(join(baseNativeModule, 'prebuilds'))).to.be.true + + await makeGetRequest({ + url: server.url, + path: '/plugins/test-native/router', + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + + await removeNativeModule() + + await server.kill() + await server.run() + + expect(await pathExists(join(baseNativeModule, 'build'))).to.be.false + expect(await pathExists(join(baseNativeModule, 'prebuilds'))).to.be.false + + await makeGetRequest({ + url: server.url, + path: '/plugins/test-native/router', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + after(async function () { + await sqlCommand.cleanup() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/proxy.ts b/packages/tests/src/api/server/proxy.ts new file mode 100644 index 000000000..c7d13f4ab --- /dev/null +++ b/packages/tests/src/api/server/proxy.ts @@ -0,0 +1,173 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode, HttpStatusCodeType, VideoPrivacy } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { expectStartWith, expectNotStartWith } from '@tests/shared/checks.js' +import { MockProxy } from '@tests/shared/mock-servers/mock-proxy.js' + +describe('Test proxy', function () { + let servers: PeerTubeServer[] = [] + let proxy: MockProxy + + const goodEnv = { HTTP_PROXY: '' } + const badEnv = { HTTP_PROXY: 'http://127.0.0.1:9000' } + + before(async function () { + this.timeout(120000) + + proxy = new MockProxy() + + const proxyPort = await proxy.initialize() + servers = await createMultipleServers(2) + + goodEnv.HTTP_PROXY = 'http://127.0.0.1:' + proxyPort + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await doubleFollow(servers[0], servers[1]) + }) + + describe('Federation', function () { + + it('Should succeed federation with the appropriate proxy config', async function () { + this.timeout(40000) + + await servers[0].kill() + await servers[0].run({}, { env: goodEnv }) + + await servers[0].videos.quickUpload({ name: 'video 1' }) + + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + } + }) + + it('Should fail federation with a wrong proxy config', async function () { + this.timeout(40000) + + await servers[0].kill() + await servers[0].run({}, { env: badEnv }) + + await servers[0].videos.quickUpload({ name: 'video 2' }) + + await waitJobs(servers) + + { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + } + + { + const { total, data } = await servers[1].videos.list() + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + } + }) + }) + + describe('Videos import', async function () { + + function quickImport (expectedStatus: HttpStatusCodeType = HttpStatusCode.OK_200) { + return servers[0].imports.importVideo({ + attributes: { + name: 'video import', + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + targetUrl: FIXTURE_URLS.peertube_long + }, + expectedStatus + }) + } + + it('Should succeed import with the appropriate proxy config', async function () { + this.timeout(240000) + + await servers[0].kill() + await servers[0].run({}, { env: goodEnv }) + + await quickImport() + + await waitJobs(servers) + + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + }) + + it('Should fail import with a wrong proxy config', async function () { + this.timeout(120000) + + await servers[0].kill() + await servers[0].run({}, { env: badEnv }) + + await quickImport(HttpStatusCode.BAD_REQUEST_400) + }) + }) + + describe('Object storage', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(30000) + + await objectStorage.prepareDefaultMockBuckets() + }) + + it('Should succeed to upload to object storage with the appropriate proxy config', async function () { + this.timeout(120000) + + await servers[0].kill() + await servers[0].run(objectStorage.getDefaultMockConfig(), { env: goodEnv }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: uuid }) + + expectStartWith(video.files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl()) + }) + + it('Should fail to upload to object storage with a wrong proxy config', async function () { + this.timeout(120000) + + await servers[0].kill() + await servers[0].run(objectStorage.getDefaultMockConfig(), { env: badEnv }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) + await waitJobs(servers, { skipDelayed: true }) + + const video = await servers[0].videos.get({ id: uuid }) + + expectNotStartWith(video.files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl()) + }) + + after(async function () { + await objectStorage.cleanupMock() + }) + }) + + after(async function () { + await proxy.terminate() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/server/reverse-proxy.ts b/packages/tests/src/api/server/reverse-proxy.ts new file mode 100644 index 000000000..7e334cc3e --- /dev/null +++ b/packages/tests/src/api/server/reverse-proxy.ts @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' + +describe('Test application behind a reverse proxy', function () { + let server: PeerTubeServer + let userAccessToken: string + let videoId: string + + before(async function () { + this.timeout(60000) + + const config = { + rates_limit: { + api: { + max: 50, + window: 5000 + }, + signup: { + max: 3, + window: 5000 + }, + login: { + max: 20 + } + }, + signup: { + limit: 20 + } + } + + server = await createSingleServer(1, config) + await setAccessTokensToServers([ server ]) + + userAccessToken = await server.users.generateUserAndToken('user') + + const { uuid } = await server.videos.upload() + videoId = uuid + }) + + it('Should view a video only once with the same IP by default', async function () { + this.timeout(40000) + + await server.views.simulateView({ id: videoId }) + await server.views.simulateView({ id: videoId }) + + // Wait the repeatable job + await wait(8000) + + const video = await server.videos.get({ id: videoId }) + expect(video.views).to.equal(1) + }) + + it('Should view a video 2 times with the X-Forwarded-For header set', async function () { + this.timeout(20000) + + await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.1,127.0.0.1' }) + await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.2,127.0.0.1' }) + + // Wait the repeatable job + await wait(8000) + + const video = await server.videos.get({ id: videoId }) + expect(video.views).to.equal(3) + }) + + it('Should view a video only once with the same client IP in the X-Forwarded-For header', async function () { + this.timeout(20000) + + await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.4,0.0.0.3,::ffff:127.0.0.1' }) + await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.5,0.0.0.3,127.0.0.1' }) + + // Wait the repeatable job + await wait(8000) + + const video = await server.videos.get({ id: videoId }) + expect(video.views).to.equal(4) + }) + + it('Should view a video two times with a different client IP in the X-Forwarded-For header', async function () { + this.timeout(20000) + + await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.8,0.0.0.6,127.0.0.1' }) + await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.8,0.0.0.7,127.0.0.1' }) + + // Wait the repeatable job + await wait(8000) + + const video = await server.videos.get({ id: videoId }) + expect(video.views).to.equal(6) + }) + + it('Should rate limit logins', async function () { + const user = { username: 'root', password: 'fail' } + + for (let i = 0; i < 18; i++) { + await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + + await server.login.login({ user, expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 }) + }) + + it('Should rate limit signup', async function () { + for (let i = 0; i < 10; i++) { + try { + await server.registrations.register({ username: 'test' + i }) + } catch { + // empty + } + } + + await server.registrations.register({ username: 'test42', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 }) + }) + + it('Should not rate limit failed signup', async function () { + this.timeout(30000) + + await wait(7000) + + for (let i = 0; i < 3; i++) { + await server.registrations.register({ username: 'test' + i, expectedStatus: HttpStatusCode.CONFLICT_409 }) + } + + await server.registrations.register({ username: 'test43', expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + + }) + + it('Should rate limit API calls', async function () { + this.timeout(30000) + + await wait(7000) + + for (let i = 0; i < 100; i++) { + try { + await server.videos.get({ id: videoId }) + } catch { + // don't care if it fails + } + } + + await server.videos.get({ id: videoId, expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 }) + }) + + it('Should rate limit API calls with a user but not with an admin', async function () { + await server.videos.get({ id: videoId, token: userAccessToken, expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 }) + + await server.videos.get({ id: videoId, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/services.ts b/packages/tests/src/api/server/services.ts new file mode 100644 index 000000000..349d29a58 --- /dev/null +++ b/packages/tests/src/api/server/services.ts @@ -0,0 +1,143 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { Video, VideoPlaylistPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test services', function () { + let server: PeerTubeServer = null + let playlistUUID: string + let playlistDisplayName: string + let video: Video + + const urlSuffixes = [ + { + input: '', + output: '' + }, + { + input: '?param=1', + output: '' + }, + { + input: '?muted=1&warningTitle=0&toto=1', + output: '?muted=1&warningTitle=0' + } + ] + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + { + const attributes = { name: 'my super name' } + await server.videos.upload({ attributes }) + + const { data } = await server.videos.list() + video = data[0] + } + + { + const created = await server.playlists.create({ + attributes: { + displayName: 'The Life and Times of Scrooge McDuck', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: server.store.channel.id + } + }) + + playlistUUID = created.uuid + playlistDisplayName = 'The Life and Times of Scrooge McDuck' + + await server.playlists.addElement({ + playlistId: created.id, + attributes: { + videoId: video.id + } + }) + } + }) + + it('Should have a valid oEmbed video response', async function () { + for (const basePath of [ '/videos/watch/', '/w/' ]) { + for (const suffix of urlSuffixes) { + const oembedUrl = server.url + basePath + video.uuid + suffix.input + + const res = await server.services.getOEmbed({ oembedUrl }) + const expectedHtml = '' + + const expectedThumbnailUrl = 'http://' + server.host + video.previewPath + + expect(res.body.html).to.equal(expectedHtml) + expect(res.body.title).to.equal(video.name) + expect(res.body.author_name).to.equal(server.store.channel.displayName) + expect(res.body.width).to.equal(560) + expect(res.body.height).to.equal(315) + expect(res.body.thumbnail_url).to.equal(expectedThumbnailUrl) + expect(res.body.thumbnail_width).to.equal(850) + expect(res.body.thumbnail_height).to.equal(480) + } + } + }) + + it('Should have a valid playlist oEmbed response', async function () { + for (const basePath of [ '/videos/watch/playlist/', '/w/p/' ]) { + for (const suffix of urlSuffixes) { + const oembedUrl = server.url + basePath + playlistUUID + suffix.input + + const res = await server.services.getOEmbed({ oembedUrl }) + const expectedHtml = '' + + expect(res.body.html).to.equal(expectedHtml) + expect(res.body.title).to.equal('The Life and Times of Scrooge McDuck') + expect(res.body.author_name).to.equal(server.store.channel.displayName) + expect(res.body.width).to.equal(560) + expect(res.body.height).to.equal(315) + expect(res.body.thumbnail_url).exist + expect(res.body.thumbnail_width).to.equal(280) + expect(res.body.thumbnail_height).to.equal(157) + } + } + }) + + it('Should have a valid oEmbed response with small max height query', async function () { + for (const basePath of [ '/videos/watch/', '/w/' ]) { + const oembedUrl = 'http://' + server.host + basePath + video.uuid + const format = 'json' + const maxHeight = 50 + const maxWidth = 50 + + const res = await server.services.getOEmbed({ oembedUrl, format, maxHeight, maxWidth }) + const expectedHtml = '' + + expect(res.body.html).to.equal(expectedHtml) + expect(res.body.title).to.equal(video.name) + expect(res.body.author_name).to.equal(server.store.channel.displayName) + expect(res.body.height).to.equal(50) + expect(res.body.width).to.equal(50) + expect(res.body).to.not.have.property('thumbnail_url') + expect(res.body).to.not.have.property('thumbnail_width') + expect(res.body).to.not.have.property('thumbnail_height') + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/server/slow-follows.ts b/packages/tests/src/api/server/slow-follows.ts new file mode 100644 index 000000000..d03109001 --- /dev/null +++ b/packages/tests/src/api/server/slow-follows.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { Job } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test slow follows', function () { + let servers: PeerTubeServer[] = [] + + let afterFollows: Date + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + await doubleFollow(servers[0], servers[2]) + + afterFollows = new Date() + + for (let i = 0; i < 5; i++) { + await servers[0].videos.quickUpload({ name: 'video ' + i }) + } + + await waitJobs(servers) + }) + + it('Should only have broadcast jobs', async function () { + const { data } = await servers[0].jobs.list({ jobType: 'activitypub-http-unicast', sort: '-createdAt' }) + + for (const job of data) { + expect(new Date(job.createdAt)).below(afterFollows) + } + }) + + it('Should process bad follower', async function () { + this.timeout(30000) + + await servers[1].kill() + + // Set server 2 as bad follower + await servers[0].videos.quickUpload({ name: 'video 6' }) + await waitJobs(servers[0]) + + afterFollows = new Date() + const filter = (job: Job) => new Date(job.createdAt) > afterFollows + + // Resend another broadcast job + await servers[0].videos.quickUpload({ name: 'video 7' }) + await waitJobs(servers[0]) + + const resBroadcast = await servers[0].jobs.list({ jobType: 'activitypub-http-broadcast', sort: '-createdAt' }) + const resUnicast = await servers[0].jobs.list({ jobType: 'activitypub-http-unicast', sort: '-createdAt' }) + + const broadcast = resBroadcast.data.filter(filter) + const unicast = resUnicast.data.filter(filter) + + expect(unicast).to.have.lengthOf(2) + expect(broadcast).to.have.lengthOf(2) + + for (const u of unicast) { + expect(u.data.uri).to.equal(servers[1].url + '/inbox') + } + + for (const b of broadcast) { + expect(b.data.uris).to.have.lengthOf(1) + expect(b.data.uris[0]).to.equal(servers[2].url + '/inbox') + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/server/stats.ts b/packages/tests/src/api/server/stats.ts new file mode 100644 index 000000000..32ab323ce --- /dev/null +++ b/packages/tests/src/api/server/stats.ts @@ -0,0 +1,279 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { ActivityType, VideoPlaylistPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test stats (excluding redundancy)', function () { + let servers: PeerTubeServer[] = [] + let channelId + const user = { + username: 'user1', + password: 'super_password' + } + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + + await setAccessTokensToServers(servers) + await setDefaultChannelAvatar(servers) + await setDefaultAccountAvatar(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].users.create({ username: user.username, password: user.password }) + + const { uuid } = await servers[0].videos.upload({ attributes: { fixture: 'video_short.webm' } }) + + await servers[0].comments.createThread({ videoId: uuid, text: 'comment' }) + + await servers[0].views.simulateView({ id: uuid }) + + // Wait the video views repeatable job + await wait(8000) + + await servers[2].follows.follow({ hosts: [ servers[0].url ] }) + await waitJobs(servers) + }) + + it('Should have the correct stats on instance 1', async function () { + const data = await servers[0].stats.get() + + expect(data.totalLocalVideoComments).to.equal(1) + expect(data.totalLocalVideos).to.equal(1) + expect(data.totalLocalVideoViews).to.equal(1) + expect(data.totalLocalVideoFilesSize).to.equal(218910) + expect(data.totalUsers).to.equal(2) + expect(data.totalVideoComments).to.equal(1) + expect(data.totalVideos).to.equal(1) + expect(data.totalInstanceFollowers).to.equal(2) + expect(data.totalInstanceFollowing).to.equal(1) + expect(data.totalLocalPlaylists).to.equal(0) + }) + + it('Should have the correct stats on instance 2', async function () { + const data = await servers[1].stats.get() + + expect(data.totalLocalVideoComments).to.equal(0) + expect(data.totalLocalVideos).to.equal(0) + expect(data.totalLocalVideoViews).to.equal(0) + expect(data.totalLocalVideoFilesSize).to.equal(0) + expect(data.totalUsers).to.equal(1) + expect(data.totalVideoComments).to.equal(1) + expect(data.totalVideos).to.equal(1) + expect(data.totalInstanceFollowers).to.equal(1) + expect(data.totalInstanceFollowing).to.equal(1) + expect(data.totalLocalPlaylists).to.equal(0) + }) + + it('Should have the correct stats on instance 3', async function () { + const data = await servers[2].stats.get() + + expect(data.totalLocalVideoComments).to.equal(0) + expect(data.totalLocalVideos).to.equal(0) + expect(data.totalLocalVideoViews).to.equal(0) + expect(data.totalUsers).to.equal(1) + expect(data.totalVideoComments).to.equal(1) + expect(data.totalVideos).to.equal(1) + expect(data.totalInstanceFollowing).to.equal(1) + expect(data.totalInstanceFollowers).to.equal(0) + expect(data.totalLocalPlaylists).to.equal(0) + }) + + it('Should have the correct total videos stats after an unfollow', async function () { + this.timeout(15000) + + await servers[2].follows.unfollow({ target: servers[0] }) + await waitJobs(servers) + + const data = await servers[2].stats.get() + + expect(data.totalVideos).to.equal(0) + }) + + it('Should have the correct active user stats', async function () { + const server = servers[0] + + { + const data = await server.stats.get() + + expect(data.totalDailyActiveUsers).to.equal(1) + expect(data.totalWeeklyActiveUsers).to.equal(1) + expect(data.totalMonthlyActiveUsers).to.equal(1) + } + + { + await server.login.getAccessToken(user) + + const data = await server.stats.get() + + expect(data.totalDailyActiveUsers).to.equal(2) + expect(data.totalWeeklyActiveUsers).to.equal(2) + expect(data.totalMonthlyActiveUsers).to.equal(2) + } + }) + + it('Should have the correct active channel stats', async function () { + const server = servers[0] + + { + const data = await server.stats.get() + + expect(data.totalLocalVideoChannels).to.equal(2) + expect(data.totalLocalDailyActiveVideoChannels).to.equal(1) + expect(data.totalLocalWeeklyActiveVideoChannels).to.equal(1) + expect(data.totalLocalMonthlyActiveVideoChannels).to.equal(1) + } + + { + const attributes = { + name: 'stats_channel', + displayName: 'My stats channel' + } + const created = await server.channels.create({ attributes }) + channelId = created.id + + const data = await server.stats.get() + + expect(data.totalLocalVideoChannels).to.equal(3) + expect(data.totalLocalDailyActiveVideoChannels).to.equal(1) + expect(data.totalLocalWeeklyActiveVideoChannels).to.equal(1) + expect(data.totalLocalMonthlyActiveVideoChannels).to.equal(1) + } + + { + await server.videos.upload({ attributes: { fixture: 'video_short.webm', channelId } }) + + const data = await server.stats.get() + + expect(data.totalLocalVideoChannels).to.equal(3) + expect(data.totalLocalDailyActiveVideoChannels).to.equal(2) + expect(data.totalLocalWeeklyActiveVideoChannels).to.equal(2) + expect(data.totalLocalMonthlyActiveVideoChannels).to.equal(2) + } + }) + + it('Should have the correct playlist stats', async function () { + const server = servers[0] + + { + const data = await server.stats.get() + expect(data.totalLocalPlaylists).to.equal(0) + } + + { + await server.playlists.create({ + attributes: { + displayName: 'playlist for count', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: channelId + } + }) + + const data = await server.stats.get() + expect(data.totalLocalPlaylists).to.equal(1) + } + }) + + it('Should correctly count video file sizes if transcoding is enabled', async function () { + this.timeout(120000) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + transcoding: { + enabled: true, + webVideos: { + enabled: true + }, + hls: { + enabled: true + }, + resolutions: { + '0p': false, + '144p': false, + '240p': false, + '360p': false, + '480p': false, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + } + } + } + }) + + await servers[0].videos.upload({ attributes: { name: 'video', fixture: 'video_short.webm' } }) + + await waitJobs(servers) + + { + const data = await servers[1].stats.get() + expect(data.totalLocalVideoFilesSize).to.equal(0) + } + + { + const data = await servers[0].stats.get() + expect(data.totalLocalVideoFilesSize).to.be.greaterThan(500000) + expect(data.totalLocalVideoFilesSize).to.be.lessThan(600000) + } + }) + + it('Should have the correct AP stats', async function () { + this.timeout(120000) + + await servers[0].config.disableTranscoding() + + const first = await servers[1].stats.get() + + for (let i = 0; i < 10; i++) { + await servers[0].videos.upload({ attributes: { name: 'video' } }) + } + + await waitJobs(servers) + + await wait(6000) + + const second = await servers[1].stats.get() + expect(second.totalActivityPubMessagesProcessed).to.be.greaterThan(first.totalActivityPubMessagesProcessed) + + const apTypes: ActivityType[] = [ + 'Create', 'Update', 'Delete', 'Follow', 'Accept', 'Announce', 'Undo', 'Like', 'Reject', 'View', 'Dislike', 'Flag' + ] + + const processed = apTypes.reduce( + (previous, type) => previous + second['totalActivityPub' + type + 'MessagesSuccesses'], + 0 + ) + expect(second.totalActivityPubMessagesProcessed).to.equal(processed) + expect(second.totalActivityPubMessagesSuccesses).to.equal(processed) + + expect(second.totalActivityPubMessagesErrors).to.equal(0) + + for (const apType of apTypes) { + expect(second['totalActivityPub' + apType + 'MessagesErrors']).to.equal(0) + } + + await wait(6000) + + const third = await servers[1].stats.get() + expect(third.totalActivityPubMessagesWaiting).to.equal(0) + expect(third.activityPubMessagesProcessedPerSecond).to.be.lessThan(second.activityPubMessagesProcessedPerSecond) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/server/tracker.ts b/packages/tests/src/api/server/tracker.ts new file mode 100644 index 000000000..4df4e4613 --- /dev/null +++ b/packages/tests/src/api/server/tracker.ts @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await,@typescript-eslint/no-floating-promises */ + +import { decode as magnetUriDecode, encode as magnetUriEncode } from 'magnet-uri' +import WebTorrent from 'webtorrent' +import { + cleanupTests, + createSingleServer, + killallServers, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test tracker', function () { + let server: PeerTubeServer + let badMagnet: string + let goodMagnet: string + + before(async function () { + this.timeout(60000) + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + { + const { uuid } = await server.videos.upload() + const video = await server.videos.get({ id: uuid }) + goodMagnet = video.files[0].magnetUri + + const parsed = magnetUriDecode(goodMagnet) + parsed.infoHash = '010597bb88b1968a5693a4fa8267c592ca65f2e9' + + badMagnet = magnetUriEncode(parsed) + } + }) + + it('Should succeed with the correct infohash', function (done) { + const webtorrent = new WebTorrent() + + const torrent = webtorrent.add(goodMagnet) + + torrent.on('error', done) + torrent.on('warning', warn => { + const message = typeof warn === 'string' ? warn : warn.message + if (message.includes('Unknown infoHash ')) return done(new Error('Error on infohash')) + }) + + torrent.on('done', done) + }) + + it('Should disable the tracker', function (done) { + this.timeout(20000) + + const errCb = () => done(new Error('Tracker is enabled')) + + killallServers([ server ]) + .then(() => server.run({ tracker: { enabled: false } })) + .then(() => { + const webtorrent = new WebTorrent() + + const torrent = webtorrent.add(goodMagnet) + + torrent.on('error', done) + torrent.on('warning', warn => { + const message = typeof warn === 'string' ? warn : warn.message + if (message.includes('disabled ')) { + torrent.off('done', errCb) + + return done() + } + }) + + torrent.on('done', errCb) + }) + }) + + it('Should return an error when adding an incorrect infohash', function (done) { + this.timeout(20000) + + killallServers([ server ]) + .then(() => server.run()) + .then(() => { + const webtorrent = new WebTorrent() + + const torrent = webtorrent.add(badMagnet) + + torrent.on('error', done) + torrent.on('warning', warn => { + const message = typeof warn === 'string' ? warn : warn.message + if (message.includes('Unknown infoHash ')) return done() + }) + + torrent.on('done', () => done(new Error('No error on infohash'))) + }) + }) + + it('Should block the IP after the failed infohash', function (done) { + const webtorrent = new WebTorrent() + + const torrent = webtorrent.add(goodMagnet) + + torrent.on('error', done) + torrent.on('warning', warn => { + const message = typeof warn === 'string' ? warn : warn.message + if (message.includes('Unsupported tracker protocol')) return done() + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/transcoding/audio-only.ts b/packages/tests/src/api/transcoding/audio-only.ts new file mode 100644 index 000000000..6d0410348 --- /dev/null +++ b/packages/tests/src/api/transcoding/audio-only.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { getAudioStream, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test audio only video transcoding', function () { + let servers: PeerTubeServer[] = [] + let videoUUID: string + let webVideoAudioFileUrl: string + let fragmentedAudioFileUrl: string + + before(async function () { + this.timeout(120000) + + const configOverride = { + transcoding: { + enabled: true, + resolutions: { + '0p': true, + '144p': false, + '240p': true, + '360p': false, + '480p': false, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + hls: { + enabled: true + }, + web_videos: { + enabled: true + } + } + } + servers = await createMultipleServers(2, configOverride) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + it('Should upload a video and transcode it', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'audio only' } }) + videoUUID = uuid + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.streamingPlaylists).to.have.lengthOf(1) + + for (const files of [ video.files, video.streamingPlaylists[0].files ]) { + expect(files).to.have.lengthOf(3) + expect(files[0].resolution.id).to.equal(720) + expect(files[1].resolution.id).to.equal(240) + expect(files[2].resolution.id).to.equal(0) + } + + if (server.serverNumber === 1) { + webVideoAudioFileUrl = video.files[2].fileUrl + fragmentedAudioFileUrl = video.streamingPlaylists[0].files[2].fileUrl + } + } + }) + + it('0p transcoded video should not have video', async function () { + const paths = [ + servers[0].servers.buildWebVideoFilePath(webVideoAudioFileUrl), + servers[0].servers.buildFragmentedFilePath(videoUUID, fragmentedAudioFileUrl) + ] + + for (const path of paths) { + const { audioStream } = await getAudioStream(path) + expect(audioStream['codec_name']).to.be.equal('aac') + expect(audioStream['bit_rate']).to.be.at.most(384 * 8000) + + const size = await getVideoStreamDimensionsInfo(path) + + expect(size.height).to.equal(0) + expect(size.width).to.equal(0) + expect(size.isPortraitMode).to.be.false + expect(size.ratio).to.equal(0) + expect(size.resolution).to.equal(0) + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/transcoding/create-transcoding.ts b/packages/tests/src/api/transcoding/create-transcoding.ts new file mode 100644 index 000000000..b0a9c7556 --- /dev/null +++ b/packages/tests/src/api/transcoding/create-transcoding.ts @@ -0,0 +1,267 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { HttpStatusCode, VideoDetails } from '@peertube/peertube-models' +import { + cleanupTests, + ConfigCommand, + createMultipleServers, + doubleFollow, + expectNoFailedTranscodingJob, + makeRawRequest, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { expectStartWith } from '@tests/shared/checks.js' +import { checkResolutionsInMasterPlaylist } from '@tests/shared/streaming-playlists.js' + +async function checkFilesInObjectStorage (objectStorage: ObjectStorageCommand, video: VideoDetails) { + for (const file of video.files) { + expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl()) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + + if (video.streamingPlaylists.length === 0) return + + const hlsPlaylist = video.streamingPlaylists[0] + for (const file of hlsPlaylist.files) { + expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + + expectStartWith(hlsPlaylist.playlistUrl, objectStorage.getMockPlaylistBaseUrl()) + await makeRawRequest({ url: hlsPlaylist.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + + expectStartWith(hlsPlaylist.segmentsSha256Url, objectStorage.getMockPlaylistBaseUrl()) + await makeRawRequest({ url: hlsPlaylist.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) +} + +function runTests (enableObjectStorage: boolean) { + let servers: PeerTubeServer[] = [] + let videoUUID: string + let publishedAt: string + + let shouldBeDeleted: string[] + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(120000) + + const config = enableObjectStorage + ? objectStorage.getDefaultMockConfig() + : {} + + // Run server 2 to have transcoding enabled + servers = await createMultipleServers(2, config) + await setAccessTokensToServers(servers) + + await servers[0].config.disableTranscoding() + + await doubleFollow(servers[0], servers[1]) + + if (enableObjectStorage) await objectStorage.prepareDefaultMockBuckets() + + const { shortUUID } = await servers[0].videos.quickUpload({ name: 'video' }) + videoUUID = shortUUID + + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: videoUUID }) + publishedAt = video.publishedAt as string + + await servers[0].config.enableTranscoding() + }) + + it('Should generate HLS', async function () { + this.timeout(60000) + + await servers[0].videos.runTranscoding({ + videoId: videoUUID, + transcodingType: 'hls' + }) + + await waitJobs(servers) + await expectNoFailedTranscodingJob(servers[0]) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) + + expect(videoDetails.files).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + } + }) + + it('Should generate Web Video', async function () { + this.timeout(60000) + + await servers[0].videos.runTranscoding({ + videoId: videoUUID, + transcodingType: 'web-video' + }) + + await waitJobs(servers) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) + + expect(videoDetails.files).to.have.lengthOf(5) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + } + }) + + it('Should generate Web Video from HLS only video', async function () { + this.timeout(60000) + + await servers[0].videos.removeAllWebVideoFiles({ videoId: videoUUID }) + await waitJobs(servers) + + await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'web-video' }) + await waitJobs(servers) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) + + expect(videoDetails.files).to.have.lengthOf(5) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + } + }) + + it('Should only generate Web Video', async function () { + this.timeout(60000) + + await servers[0].videos.removeHLSPlaylist({ videoId: videoUUID }) + await waitJobs(servers) + + await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'web-video' }) + await waitJobs(servers) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) + + expect(videoDetails.files).to.have.lengthOf(5) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(0) + + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + } + }) + + it('Should correctly update HLS playlist on resolution change', async function () { + this.timeout(120000) + + await servers[0].config.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: true, + resolutions: ConfigCommand.getCustomConfigResolutions(false), + + webVideos: { + enabled: true + }, + hls: { + enabled: true + } + } + } + }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'quick' }) + + await waitJobs(servers) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: uuid }) + + expect(videoDetails.files).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(1) + + if (enableObjectStorage) await checkFilesInObjectStorage(objectStorage, videoDetails) + + shouldBeDeleted = [ + videoDetails.streamingPlaylists[0].files[0].fileUrl, + videoDetails.streamingPlaylists[0].playlistUrl, + videoDetails.streamingPlaylists[0].segmentsSha256Url + ] + } + + await servers[0].config.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: true, + resolutions: ConfigCommand.getCustomConfigResolutions(true), + + webVideos: { + enabled: true + }, + hls: { + enabled: true + } + } + } + }) + + await servers[0].videos.runTranscoding({ videoId: uuid, transcodingType: 'hls' }) + await waitJobs(servers) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: uuid }) + + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(5) + + if (enableObjectStorage) { + await checkFilesInObjectStorage(objectStorage, videoDetails) + + const hlsPlaylist = videoDetails.streamingPlaylists[0] + const resolutions = hlsPlaylist.files.map(f => f.resolution.id) + await checkResolutionsInMasterPlaylist({ server: servers[0], playlistUrl: hlsPlaylist.playlistUrl, resolutions }) + + const shaBody = await servers[0].streamingPlaylists.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, withRetry: true }) + expect(Object.keys(shaBody)).to.have.lengthOf(5) + } + } + }) + + it('Should have correctly deleted previous files', async function () { + for (const fileUrl of shouldBeDeleted) { + await makeRawRequest({ url: fileUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + }) + + it('Should not have updated published at attributes', async function () { + const video = await servers[0].videos.get({ id: videoUUID }) + + expect(video.publishedAt).to.equal(publishedAt) + }) + + after(async function () { + if (objectStorage) await objectStorage.cleanupMock() + + await cleanupTests(servers) + }) +} + +describe('Test create transcoding jobs from API', function () { + + describe('On filesystem', function () { + runTests(false) + }) + + describe('On object storage', function () { + if (areMockObjectStorageTestsDisabled()) return + + runTests(true) + }) +}) diff --git a/packages/tests/src/api/transcoding/hls.ts b/packages/tests/src/api/transcoding/hls.ts new file mode 100644 index 000000000..884f98e87 --- /dev/null +++ b/packages/tests/src/api/transcoding/hls.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { join } from 'path' +import { HttpStatusCode } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { DEFAULT_AUDIO_RESOLUTION } from '@peertube/peertube-server/server/initializers/constants.js' +import { checkDirectoryIsEmpty, checkTmpIsEmpty } from '@tests/shared/directories.js' +import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' + +describe('Test HLS videos', function () { + let servers: PeerTubeServer[] = [] + + function runTestSuite (hlsOnly: boolean, objectStorageBaseUrl?: string) { + const videoUUIDs: string[] = [] + + it('Should upload a video and transcode it to HLS', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video 1', fixture: 'video_short.webm' } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, objectStorageBaseUrl }) + }) + + it('Should upload an audio file and transcode it to HLS', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video audio', fixture: 'sample.ogg' } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ + servers, + videoUUID: uuid, + hlsOnly, + resolutions: [ DEFAULT_AUDIO_RESOLUTION, 360, 240 ], + objectStorageBaseUrl + }) + }) + + it('Should update the video', async function () { + this.timeout(30000) + + await servers[0].videos.update({ id: videoUUIDs[0], attributes: { name: 'video 1 updated' } }) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: videoUUIDs[0], hlsOnly, objectStorageBaseUrl }) + }) + + it('Should delete videos', async function () { + for (const uuid of videoUUIDs) { + await servers[0].videos.remove({ id: uuid }) + } + + await waitJobs(servers) + + for (const server of servers) { + for (const uuid of videoUUIDs) { + await server.videos.get({ id: uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + } + }) + + it('Should have the playlists/segment deleted from the disk', async function () { + for (const server of servers) { + await checkDirectoryIsEmpty(server, 'web-videos', [ 'private' ]) + await checkDirectoryIsEmpty(server, join('web-videos', 'private')) + + await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls'), [ 'private' ]) + await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls', 'private')) + } + }) + + it('Should have an empty tmp directory', async function () { + for (const server of servers) { + await checkTmpIsEmpty(server) + } + }) + } + + before(async function () { + this.timeout(120000) + + const configOverride = { + transcoding: { + enabled: true, + allow_audio_files: true, + hls: { + enabled: true + } + } + } + servers = await createMultipleServers(2, configOverride) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + describe('With Web Video & HLS enabled', function () { + runTestSuite(false) + }) + + describe('With only HLS enabled', function () { + + before(async function () { + await servers[0].config.updateCustomSubConfig({ + newConfig: { + transcoding: { + enabled: true, + allowAudioFiles: true, + resolutions: { + '144p': false, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + }, + hls: { + enabled: true + }, + webVideos: { + enabled: false + } + } + } + }) + }) + + runTestSuite(true) + }) + + describe('With object storage enabled', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(120000) + + const configOverride = objectStorage.getDefaultMockConfig() + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].kill() + await servers[0].run(configOverride) + }) + + runTestSuite(true, objectStorage.getMockPlaylistBaseUrl()) + + after(async function () { + await objectStorage.cleanupMock() + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/transcoding/index.ts b/packages/tests/src/api/transcoding/index.ts new file mode 100644 index 000000000..c25cd51c3 --- /dev/null +++ b/packages/tests/src/api/transcoding/index.ts @@ -0,0 +1,6 @@ +export * from './audio-only.js' +export * from './create-transcoding.js' +export * from './hls.js' +export * from './transcoder.js' +export * from './update-while-transcoding.js' +export * from './video-studio.js' diff --git a/packages/tests/src/api/transcoding/transcoder.ts b/packages/tests/src/api/transcoding/transcoder.ts new file mode 100644 index 000000000..8900491f5 --- /dev/null +++ b/packages/tests/src/api/transcoding/transcoder.ts @@ -0,0 +1,802 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { getAllFiles, getMaxTheoreticalBitrate, getMinTheoreticalBitrate, omit } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoFileMetadata, VideoState } from '@peertube/peertube-models' +import { canDoQuickTranscode } from '@peertube/peertube-server/server/lib/transcoding/transcoding-quick-transcode.js' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + ffprobePromise, + getAudioStream, + getVideoStreamBitrate, + getVideoStreamDimensionsInfo, + getVideoStreamFPS, + hasAudioStream +} from '@peertube/peertube-ffmpeg' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { generateVideoWithFramerate, generateHighBitrateVideo } from '@tests/shared/generate.js' +import { checkWebTorrentWorks } from '@tests/shared/webtorrent.js' + +function updateConfigForTranscoding (server: PeerTubeServer) { + return server.config.updateCustomSubConfig({ + newConfig: { + transcoding: { + enabled: true, + allowAdditionalExtensions: true, + allowAudioFiles: true, + hls: { enabled: true }, + webVideos: { enabled: true }, + resolutions: { + '0p': false, + '144p': true, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + } + } + } + }) +} + +describe('Test video transcoding', function () { + let servers: PeerTubeServer[] = [] + let video4k: string + + before(async function () { + this.timeout(30_000) + + // Run servers + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + await updateConfigForTranscoding(servers[1]) + }) + + describe('Basic transcoding (or not)', function () { + + it('Should not transcode video on server 1', async function () { + this.timeout(60_000) + + const attributes = { + name: 'my super name for server 1', + description: 'my super description for server 1', + fixture: 'video_short.webm' + } + await servers[0].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + const video = data[0] + + const videoDetails = await server.videos.get({ id: video.id }) + expect(videoDetails.files).to.have.lengthOf(1) + + const magnetUri = videoDetails.files[0].magnetUri + expect(magnetUri).to.match(/\.webm/) + + await checkWebTorrentWorks(magnetUri, /\.webm$/) + } + }) + + it('Should transcode video on server 2', async function () { + this.timeout(120_000) + + const attributes = { + name: 'my super name for server 2', + description: 'my super description for server 2', + fixture: 'video_short.webm' + } + await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video = data.find(v => v.name === attributes.name) + const videoDetails = await server.videos.get({ id: video.id }) + + expect(videoDetails.files).to.have.lengthOf(5) + + const magnetUri = videoDetails.files[0].magnetUri + expect(magnetUri).to.match(/\.mp4/) + + await checkWebTorrentWorks(magnetUri, /\.mp4$/) + } + }) + + it('Should wait for transcoding before publishing the video', async function () { + this.timeout(160_000) + + { + // Upload the video, but wait transcoding + const attributes = { + name: 'waiting video', + fixture: 'video_short1.webm', + waitTranscoding: true + } + const { uuid } = await servers[1].videos.upload({ attributes }) + const videoId = uuid + + // Should be in transcode state + const body = await servers[1].videos.get({ id: videoId }) + expect(body.name).to.equal('waiting video') + expect(body.state.id).to.equal(VideoState.TO_TRANSCODE) + expect(body.state.label).to.equal('To transcode') + expect(body.waitTranscoding).to.be.true + + { + // Should have my video + const { data } = await servers[1].videos.listMyVideos() + const videoToFindInMine = data.find(v => v.name === attributes.name) + expect(videoToFindInMine).not.to.be.undefined + expect(videoToFindInMine.state.id).to.equal(VideoState.TO_TRANSCODE) + expect(videoToFindInMine.state.label).to.equal('To transcode') + expect(videoToFindInMine.waitTranscoding).to.be.true + } + + { + // Should not list this video + const { data } = await servers[1].videos.list() + const videoToFindInList = data.find(v => v.name === attributes.name) + expect(videoToFindInList).to.be.undefined + } + + // Server 1 should not have the video yet + await servers[0].videos.get({ id: videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + const videoToFind = data.find(v => v.name === 'waiting video') + expect(videoToFind).not.to.be.undefined + + const videoDetails = await server.videos.get({ id: videoToFind.id }) + + expect(videoDetails.state.id).to.equal(VideoState.PUBLISHED) + expect(videoDetails.state.label).to.equal('Published') + expect(videoDetails.waitTranscoding).to.be.true + } + }) + + it('Should accept and transcode additional extensions', async function () { + this.timeout(300_000) + + for (const fixture of [ 'video_short.mkv', 'video_short.avi' ]) { + const attributes = { + name: fixture, + fixture + } + + await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video = data.find(v => v.name === attributes.name) + const videoDetails = await server.videos.get({ id: video.id }) + expect(videoDetails.files).to.have.lengthOf(5) + + const magnetUri = videoDetails.files[0].magnetUri + expect(magnetUri).to.contain('.mp4') + } + } + }) + + it('Should transcode a 4k video', async function () { + this.timeout(200_000) + + const attributes = { + name: '4k video', + fixture: 'video_short_4k.mp4' + } + + const { uuid } = await servers[1].videos.upload({ attributes }) + video4k = uuid + + await waitJobs(servers) + + const resolutions = [ 144, 240, 360, 480, 720, 1080, 1440, 2160 ] + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: video4k }) + expect(videoDetails.files).to.have.lengthOf(resolutions.length) + + for (const r of resolutions) { + expect(videoDetails.files.find(f => f.resolution.id === r)).to.not.be.undefined + expect(videoDetails.streamingPlaylists[0].files.find(f => f.resolution.id === r)).to.not.be.undefined + } + } + }) + }) + + describe('Audio transcoding', function () { + + it('Should transcode high bit rate mp3 to proper bit rate', async function () { + this.timeout(60_000) + + const attributes = { + name: 'mp3_256k', + fixture: 'video_short_mp3_256k.mp4' + } + await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video = data.find(v => v.name === attributes.name) + const videoDetails = await server.videos.get({ id: video.id }) + + expect(videoDetails.files).to.have.lengthOf(5) + + const file = videoDetails.files.find(f => f.resolution.id === 240) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + const probe = await getAudioStream(path) + + if (probe.audioStream) { + expect(probe.audioStream['codec_name']).to.be.equal('aac') + expect(probe.audioStream['bit_rate']).to.be.at.most(384 * 8000) + } else { + this.fail('Could not retrieve the audio stream on ' + probe.absolutePath) + } + } + }) + + it('Should transcode video with no audio and have no audio itself', async function () { + this.timeout(60_000) + + const attributes = { + name: 'no_audio', + fixture: 'video_short_no_audio.mp4' + } + await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video = data.find(v => v.name === attributes.name) + const videoDetails = await server.videos.get({ id: video.id }) + + const file = videoDetails.files.find(f => f.resolution.id === 240) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + + expect(await hasAudioStream(path)).to.be.false + } + }) + + it('Should leave the audio untouched, but properly transcode the video', async function () { + this.timeout(60_000) + + const attributes = { + name: 'untouched_audio', + fixture: 'video_short.mp4' + } + await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video = data.find(v => v.name === attributes.name) + const videoDetails = await server.videos.get({ id: video.id }) + + expect(videoDetails.files).to.have.lengthOf(5) + + const fixturePath = buildAbsoluteFixturePath(attributes.fixture) + const fixtureVideoProbe = await getAudioStream(fixturePath) + + const file = videoDetails.files.find(f => f.resolution.id === 240) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + + const videoProbe = await getAudioStream(path) + + if (videoProbe.audioStream && fixtureVideoProbe.audioStream) { + const toOmit = [ 'max_bit_rate', 'duration', 'duration_ts', 'nb_frames', 'start_time', 'start_pts' ] + expect(omit(videoProbe.audioStream, toOmit)).to.be.deep.equal(omit(fixtureVideoProbe.audioStream, toOmit)) + } else { + this.fail('Could not retrieve the audio stream on ' + videoProbe.absolutePath) + } + } + }) + }) + + describe('Audio upload', function () { + + function runSuite (mode: 'legacy' | 'resumable') { + + before(async function () { + await servers[1].config.updateCustomSubConfig({ + newConfig: { + transcoding: { + hls: { enabled: true }, + webVideos: { enabled: true }, + resolutions: { + '0p': false, + '144p': false, + '240p': false, + '360p': false, + '480p': false, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + } + } + } + }) + }) + + it('Should merge an audio file with the preview file', async function () { + this.timeout(60_000) + + const attributes = { name: 'audio_with_preview', previewfile: 'custom-preview.jpg', fixture: 'sample.ogg' } + await servers[1].videos.upload({ attributes, mode }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video = data.find(v => v.name === 'audio_with_preview') + const videoDetails = await server.videos.get({ id: video.id }) + + expect(videoDetails.files).to.have.lengthOf(1) + + await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + + const magnetUri = videoDetails.files[0].magnetUri + expect(magnetUri).to.contain('.mp4') + } + }) + + it('Should upload an audio file and choose a default background image', async function () { + this.timeout(60_000) + + const attributes = { name: 'audio_without_preview', fixture: 'sample.ogg' } + await servers[1].videos.upload({ attributes, mode }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video = data.find(v => v.name === 'audio_without_preview') + const videoDetails = await server.videos.get({ id: video.id }) + + expect(videoDetails.files).to.have.lengthOf(1) + + await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + + const magnetUri = videoDetails.files[0].magnetUri + expect(magnetUri).to.contain('.mp4') + } + }) + + it('Should upload an audio file and create an audio version only', async function () { + this.timeout(60_000) + + await servers[1].config.updateCustomSubConfig({ + newConfig: { + transcoding: { + hls: { enabled: true }, + webVideos: { enabled: true }, + resolutions: { + '0p': true, + '144p': false, + '240p': false, + '360p': false + } + } + } + }) + + const attributes = { name: 'audio_with_preview', previewfile: 'custom-preview.jpg', fixture: 'sample.ogg' } + const { id } = await servers[1].videos.upload({ attributes, mode }) + + await waitJobs(servers) + + for (const server of servers) { + const videoDetails = await server.videos.get({ id }) + + for (const files of [ videoDetails.files, videoDetails.streamingPlaylists[0].files ]) { + expect(files).to.have.lengthOf(2) + expect(files.find(f => f.resolution.id === 0)).to.not.be.undefined + } + } + + await updateConfigForTranscoding(servers[1]) + }) + } + + describe('Legacy upload', function () { + runSuite('legacy') + }) + + describe('Resumable upload', function () { + runSuite('resumable') + }) + }) + + describe('Framerate', function () { + + it('Should transcode a 60 FPS video', async function () { + this.timeout(60_000) + + const attributes = { + name: 'my super 30fps name for server 2', + description: 'my super 30fps description for server 2', + fixture: '60fps_720p_small.mp4' + } + await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video = data.find(v => v.name === attributes.name) + const videoDetails = await server.videos.get({ id: video.id }) + + expect(videoDetails.files).to.have.lengthOf(5) + expect(videoDetails.files[0].fps).to.be.above(58).and.below(62) + expect(videoDetails.files[1].fps).to.be.below(31) + expect(videoDetails.files[2].fps).to.be.below(31) + expect(videoDetails.files[3].fps).to.be.below(31) + expect(videoDetails.files[4].fps).to.be.below(31) + + for (const resolution of [ 144, 240, 360, 480 ]) { + const file = videoDetails.files.find(f => f.resolution.id === resolution) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + const fps = await getVideoStreamFPS(path) + + expect(fps).to.be.below(31) + } + + const file = videoDetails.files.find(f => f.resolution.id === 720) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + const fps = await getVideoStreamFPS(path) + + expect(fps).to.be.above(58).and.below(62) + } + }) + + it('Should downscale to the closest divisor standard framerate', async function () { + this.timeout(200_000) + + let tempFixturePath: string + + { + tempFixturePath = await generateVideoWithFramerate(59) + + const fps = await getVideoStreamFPS(tempFixturePath) + expect(fps).to.be.equal(59) + } + + const attributes = { + name: '59fps video', + description: '59fps video', + fixture: tempFixturePath + } + + await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const { id } = data.find(v => v.name === attributes.name) + const video = await server.videos.get({ id }) + + { + const file = video.files.find(f => f.resolution.id === 240) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + const fps = await getVideoStreamFPS(path) + expect(fps).to.be.equal(25) + } + + { + const file = video.files.find(f => f.resolution.id === 720) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + const fps = await getVideoStreamFPS(path) + expect(fps).to.be.equal(59) + } + } + }) + }) + + describe('Bitrate control', function () { + + it('Should respect maximum bitrate values', async function () { + this.timeout(160_000) + + const tempFixturePath = await generateHighBitrateVideo() + + const attributes = { + name: 'high bitrate video', + description: 'high bitrate video', + fixture: tempFixturePath + } + + await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const { id } = data.find(v => v.name === attributes.name) + const video = await server.videos.get({ id }) + + for (const resolution of [ 240, 360, 480, 720, 1080 ]) { + const file = video.files.find(f => f.resolution.id === resolution) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + + const bitrate = await getVideoStreamBitrate(path) + const fps = await getVideoStreamFPS(path) + const dataResolution = await getVideoStreamDimensionsInfo(path) + + expect(resolution).to.equal(resolution) + + const maxBitrate = getMaxTheoreticalBitrate({ ...dataResolution, fps }) + expect(bitrate).to.be.below(maxBitrate) + } + } + }) + + it('Should not transcode to an higher bitrate than the original file but above our low limit', async function () { + this.timeout(160_000) + + const newConfig = { + transcoding: { + enabled: true, + resolutions: { + '144p': true, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + }, + webVideos: { enabled: true }, + hls: { enabled: true } + } + } + await servers[1].config.updateCustomSubConfig({ newConfig }) + + const attributes = { + name: 'low bitrate', + fixture: 'low-bitrate.mp4' + } + + const { id } = await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + const video = await servers[1].videos.get({ id }) + + const resolutions = [ 240, 360, 480, 720, 1080 ] + for (const r of resolutions) { + const file = video.files.find(f => f.resolution.id === r) + + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + const bitrate = await getVideoStreamBitrate(path) + + const inputBitrate = 60_000 + const limit = getMinTheoreticalBitrate({ fps: 10, ratio: 1, resolution: r }) + let belowValue = Math.max(inputBitrate, limit) + belowValue += belowValue * 0.20 // Apply 20% margin because bitrate control is not very precise + + expect(bitrate, `${path} not below ${limit}`).to.be.below(belowValue) + } + }) + }) + + describe('FFprobe', function () { + + it('Should provide valid ffprobe data', async function () { + this.timeout(160_000) + + const videoUUID = (await servers[1].videos.quickUpload({ name: 'ffprobe data' })).uuid + await waitJobs(servers) + + { + const video = await servers[1].videos.get({ id: videoUUID }) + const file = video.files.find(f => f.resolution.id === 240) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + + const probe = await ffprobePromise(path) + const metadata = new VideoFileMetadata(probe) + + // expected format properties + for (const p of [ + 'tags.encoder', + 'format_long_name', + 'size', + 'bit_rate' + ]) { + expect(metadata.format).to.have.nested.property(p) + } + + // expected stream properties + for (const p of [ + 'codec_long_name', + 'profile', + 'width', + 'height', + 'display_aspect_ratio', + 'avg_frame_rate', + 'pix_fmt' + ]) { + expect(metadata.streams[0]).to.have.nested.property(p) + } + + expect(metadata).to.not.have.nested.property('format.filename') + } + + for (const server of servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) + + const videoFiles = getAllFiles(videoDetails) + expect(videoFiles).to.have.lengthOf(10) + + for (const file of videoFiles) { + expect(file.metadata).to.be.undefined + expect(file.metadataUrl).to.exist + expect(file.metadataUrl).to.contain(servers[1].url) + expect(file.metadataUrl).to.contain(videoUUID) + + const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl }) + expect(metadata).to.have.nested.property('format.size') + } + } + }) + + it('Should correctly detect if quick transcode is possible', async function () { + this.timeout(10_000) + + expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.mp4'))).to.be.true + expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false + }) + }) + + describe('Transcoding job queue', function () { + + it('Should have the appropriate priorities for transcoding jobs', async function () { + const body = await servers[1].jobs.list({ + start: 0, + count: 100, + sort: 'createdAt', + jobType: 'video-transcoding' + }) + + const jobs = body.data + const transcodingJobs = jobs.filter(j => j.data.videoUUID === video4k) + + expect(transcodingJobs).to.have.lengthOf(16) + + const hlsJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-hls') + const webVideoJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-web-video') + const optimizeJobs = transcodingJobs.filter(j => j.data.type === 'optimize-to-web-video') + + expect(hlsJobs).to.have.lengthOf(8) + expect(webVideoJobs).to.have.lengthOf(7) + expect(optimizeJobs).to.have.lengthOf(1) + + for (const j of optimizeJobs.concat(hlsJobs.concat(webVideoJobs))) { + expect(j.priority).to.be.greaterThan(100) + expect(j.priority).to.be.lessThan(150) + } + }) + }) + + describe('Bounded transcoding', function () { + + it('Should not generate an upper resolution than original file', async function () { + this.timeout(120_000) + + await servers[0].config.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: true, + hls: { enabled: true }, + webVideos: { enabled: true }, + resolutions: { + '0p': false, + '144p': false, + '240p': true, + '360p': false, + '480p': true, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + alwaysTranscodeOriginalResolution: false + } + } + }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: uuid }) + const hlsFiles = video.streamingPlaylists[0].files + + expect(video.files).to.have.lengthOf(2) + expect(hlsFiles).to.have.lengthOf(2) + + // eslint-disable-next-line @typescript-eslint/require-array-sort-compare + const resolutions = getAllFiles(video).map(f => f.resolution.id).sort() + expect(resolutions).to.deep.equal([ 240, 240, 480, 480 ]) + }) + + it('Should only keep the original resolution if all resolutions are disabled', async function () { + this.timeout(120_000) + + await servers[0].config.updateExistingSubConfig({ + newConfig: { + transcoding: { + resolutions: { + '0p': false, + '144p': false, + '240p': false, + '360p': false, + '480p': false, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + } + } + } + }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: uuid }) + const hlsFiles = video.streamingPlaylists[0].files + + expect(video.files).to.have.lengthOf(1) + expect(hlsFiles).to.have.lengthOf(1) + + expect(video.files[0].resolution.id).to.equal(720) + expect(hlsFiles[0].resolution.id).to.equal(720) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/transcoding/update-while-transcoding.ts b/packages/tests/src/api/transcoding/update-while-transcoding.ts new file mode 100644 index 000000000..9990bc745 --- /dev/null +++ b/packages/tests/src/api/transcoding/update-while-transcoding.ts @@ -0,0 +1,161 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { wait } from '@peertube/peertube-core-utils' +import { VideoPrivacy } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' + +describe('Test update video privacy while transcoding', function () { + let servers: PeerTubeServer[] = [] + + const videoUUIDs: string[] = [] + + function runTestSuite (hlsOnly: boolean, objectStorageBaseUrl?: string) { + + it('Should not have an error while quickly updating a private video to public after upload #1', async function () { + this.timeout(360_000) + + const attributes = { + name: 'quick update', + privacy: VideoPrivacy.PRIVATE + } + + const { uuid } = await servers[0].videos.upload({ attributes, waitTorrentGeneration: false }) + await servers[0].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, objectStorageBaseUrl }) + }) + + it('Should not have an error while quickly updating a private video to public after upload #2', async function () { + this.timeout(60000) + + { + const attributes = { + name: 'quick update 2', + privacy: VideoPrivacy.PRIVATE + } + + const { uuid } = await servers[0].videos.upload({ attributes, waitTorrentGeneration: true }) + await servers[0].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, objectStorageBaseUrl }) + } + }) + + it('Should not have an error while quickly updating a private video to public after upload #3', async function () { + this.timeout(60000) + + const attributes = { + name: 'quick update 3', + privacy: VideoPrivacy.PRIVATE + } + + const { uuid } = await servers[0].videos.upload({ attributes, waitTorrentGeneration: true }) + await wait(1000) + await servers[0].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, objectStorageBaseUrl }) + }) + } + + before(async function () { + this.timeout(120000) + + const configOverride = { + transcoding: { + enabled: true, + allow_audio_files: true, + hls: { + enabled: true + } + } + } + servers = await createMultipleServers(2, configOverride) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + describe('With Web Video & HLS enabled', function () { + runTestSuite(false) + }) + + describe('With only HLS enabled', function () { + + before(async function () { + await servers[0].config.updateCustomSubConfig({ + newConfig: { + transcoding: { + enabled: true, + allowAudioFiles: true, + resolutions: { + '144p': false, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + }, + hls: { + enabled: true + }, + webVideos: { + enabled: false + } + } + } + }) + }) + + runTestSuite(true) + }) + + describe('With object storage enabled', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(120000) + + const configOverride = objectStorage.getDefaultMockConfig() + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].kill() + await servers[0].run(configOverride) + }) + + runTestSuite(true, objectStorage.getMockPlaylistBaseUrl()) + + after(async function () { + await objectStorage.cleanupMock() + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/transcoding/video-studio.ts b/packages/tests/src/api/transcoding/video-studio.ts new file mode 100644 index 000000000..8a3788aa6 --- /dev/null +++ b/packages/tests/src/api/transcoding/video-studio.ts @@ -0,0 +1,379 @@ +import { expect } from 'chai' +import { getAllFiles } from '@peertube/peertube-core-utils' +import { VideoStudioTask } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + VideoStudioCommand, + waitJobs +} from '@peertube/peertube-server-commands' +import { checkVideoDuration, expectStartWith } from '@tests/shared/checks.js' +import { checkPersistentTmpIsEmpty } from '@tests/shared/directories.js' + +describe('Test video studio', function () { + let servers: PeerTubeServer[] = [] + let videoUUID: string + + async function renewVideo (fixture = 'video_short.webm') { + const video = await servers[0].videos.quickUpload({ name: 'video', fixture }) + videoUUID = video.uuid + + await waitJobs(servers) + } + + async function createTasks (tasks: VideoStudioTask[]) { + await servers[0].videoStudio.createEditionTasks({ videoId: videoUUID, tasks }) + await waitJobs(servers) + } + + before(async function () { + this.timeout(120_000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableMinimumTranscoding() + + await servers[0].config.enableStudio() + }) + + describe('Cutting', function () { + + it('Should cut the beginning of the video', async function () { + this.timeout(120_000) + + await renewVideo() + await waitJobs(servers) + + const beforeTasks = new Date() + + await createTasks([ + { + name: 'cut', + options: { + start: 2 + } + } + ]) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 3) + + const video = await server.videos.get({ id: videoUUID }) + expect(new Date(video.publishedAt)).to.be.below(beforeTasks) + } + }) + + it('Should cut the end of the video', async function () { + this.timeout(120_000) + await renewVideo() + + await createTasks([ + { + name: 'cut', + options: { + end: 2 + } + } + ]) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 2) + } + }) + + it('Should cut start/end of the video', async function () { + this.timeout(120_000) + await renewVideo('video_short1.webm') // 10 seconds video duration + + await createTasks([ + { + name: 'cut', + options: { + start: 2, + end: 6 + } + } + ]) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 4) + } + }) + }) + + describe('Intro/Outro', function () { + + it('Should add an intro', async function () { + this.timeout(120_000) + await renewVideo() + + await createTasks([ + { + name: 'add-intro', + options: { + file: 'video_short.webm' + } + } + ]) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 10) + } + }) + + it('Should add an outro', async function () { + this.timeout(120_000) + await renewVideo() + + await createTasks([ + { + name: 'add-outro', + options: { + file: 'video_very_short_240p.mp4' + } + } + ]) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 7) + } + }) + + it('Should add an intro/outro', async function () { + this.timeout(120_000) + await renewVideo() + + await createTasks([ + { + name: 'add-intro', + options: { + file: 'video_very_short_240p.mp4' + } + }, + { + name: 'add-outro', + options: { + // Different frame rate + file: 'video_short2.webm' + } + } + ]) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 12) + } + }) + + it('Should add an intro to a video without audio', async function () { + this.timeout(120_000) + await renewVideo('video_short_no_audio.mp4') + + await createTasks([ + { + name: 'add-intro', + options: { + file: 'video_very_short_240p.mp4' + } + } + ]) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 7) + } + }) + + it('Should add an outro without audio to a video with audio', async function () { + this.timeout(120_000) + await renewVideo() + + await createTasks([ + { + name: 'add-outro', + options: { + file: 'video_short_no_audio.mp4' + } + } + ]) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 10) + } + }) + + it('Should add an outro without audio to a video with audio', async function () { + this.timeout(120_000) + await renewVideo('video_short_no_audio.mp4') + + await createTasks([ + { + name: 'add-outro', + options: { + file: 'video_short_no_audio.mp4' + } + } + ]) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 10) + } + }) + }) + + describe('Watermark', function () { + + it('Should add a watermark to the video', async function () { + this.timeout(120_000) + await renewVideo() + + const video = await servers[0].videos.get({ id: videoUUID }) + const oldFileUrls = getAllFiles(video).map(f => f.fileUrl) + + await createTasks([ + { + name: 'add-watermark', + options: { + file: 'custom-thumbnail.png' + } + } + ]) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + const fileUrls = getAllFiles(video).map(f => f.fileUrl) + + for (const oldUrl of oldFileUrls) { + expect(fileUrls).to.not.include(oldUrl) + } + } + }) + }) + + describe('Complex tasks', function () { + it('Should run a complex task', async function () { + this.timeout(240_000) + await renewVideo() + + await createTasks(VideoStudioCommand.getComplexTask()) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 9) + } + }) + }) + + describe('HLS only studio edition', function () { + + before(async function () { + // Disable Web Videos + await servers[0].config.updateExistingSubConfig({ + newConfig: { + transcoding: { + webVideos: { + enabled: false + } + } + } + }) + }) + + it('Should run a complex task on HLS only video', async function () { + this.timeout(240_000) + await renewVideo() + + await createTasks(VideoStudioCommand.getComplexTask()) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.files).to.have.lengthOf(0) + + await checkVideoDuration(server, videoUUID, 9) + } + }) + }) + + describe('Server restart', function () { + + it('Should still be able to run video edition after a server restart', async function () { + this.timeout(240_000) + + await renewVideo() + await servers[0].videoStudio.createEditionTasks({ videoId: videoUUID, tasks: VideoStudioCommand.getComplexTask() }) + + await servers[0].kill() + await servers[0].run() + + await waitJobs(servers) + + for (const server of servers) { + await checkVideoDuration(server, videoUUID, 9) + } + }) + + it('Should have an empty persistent tmp directory', async function () { + await checkPersistentTmpIsEmpty(servers[0]) + }) + }) + + describe('Object storage studio edition', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].kill() + await servers[0].run(objectStorage.getDefaultMockConfig()) + + await servers[0].config.enableMinimumTranscoding() + }) + + it('Should run a complex task on a video in object storage', async function () { + this.timeout(240_000) + await renewVideo() + + const video = await servers[0].videos.get({ id: videoUUID }) + const oldFileUrls = getAllFiles(video).map(f => f.fileUrl) + + await createTasks(VideoStudioCommand.getComplexTask()) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + const files = getAllFiles(video) + + for (const f of files) { + expect(oldFileUrls).to.not.include(f.fileUrl) + } + + for (const webVideoFile of video.files) { + expectStartWith(webVideoFile.fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + + for (const hlsFile of video.streamingPlaylists[0].files) { + expectStartWith(hlsFile.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + } + + await checkVideoDuration(server, videoUUID, 9) + } + }) + + after(async function () { + await objectStorage.cleanupMock() + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/users/index.ts b/packages/tests/src/api/users/index.ts new file mode 100644 index 000000000..830d4da62 --- /dev/null +++ b/packages/tests/src/api/users/index.ts @@ -0,0 +1,8 @@ +import './oauth.js' +import './registrations`.js' +import './two-factor.js' +import './user-subscriptions.js' +import './user-videos.js' +import './users.js' +import './users-multiple-servers.js' +import './users-email-verification.js' diff --git a/packages/tests/src/api/users/oauth.ts b/packages/tests/src/api/users/oauth.ts new file mode 100644 index 000000000..fe50872cb --- /dev/null +++ b/packages/tests/src/api/users/oauth.ts @@ -0,0 +1,203 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@peertube/peertube-models' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { + cleanupTests, + createSingleServer, + killallServers, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test oauth', function () { + let server: PeerTubeServer + let sqlCommand: SQLCommand + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, { + rates_limit: { + login: { + max: 30 + } + } + }) + + await setAccessTokensToServers([ server ]) + + sqlCommand = new SQLCommand(server) + }) + + describe('OAuth client', function () { + + function expectInvalidClient (body: PeerTubeProblemDocument) { + expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) + expect(body.error).to.contain('client is invalid') + expect(body.type.startsWith('https://')).to.be.true + expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) + } + + it('Should create a new client') + + it('Should return the first client') + + it('Should remove the last client') + + it('Should not login with an invalid client id', async function () { + const client = { id: 'client', secret: server.store.client.secret } + const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidClient(body) + }) + + it('Should not login with an invalid client secret', async function () { + const client = { id: server.store.client.id, secret: 'coucou' } + const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidClient(body) + }) + }) + + describe('Login', function () { + + function expectInvalidCredentials (body: PeerTubeProblemDocument) { + expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) + expect(body.error).to.contain('credentials are invalid') + expect(body.type.startsWith('https://')).to.be.true + expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) + } + + it('Should not login with an invalid username', async function () { + const user = { username: 'captain crochet', password: server.store.user.password } + const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidCredentials(body) + }) + + it('Should not login with an invalid password', async function () { + const user = { username: server.store.user.username, password: 'mew_three' } + const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidCredentials(body) + }) + + it('Should be able to login', async function () { + await server.login.login({ expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should be able to login with an insensitive username', async function () { + const user = { username: 'RoOt', password: server.store.user.password } + await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 }) + + const user2 = { username: 'rOoT', password: server.store.user.password } + await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 }) + + const user3 = { username: 'ROOt', password: server.store.user.password } + await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('Logout', function () { + + it('Should logout (revoke token)', async function () { + await server.login.logout({ token: server.accessToken }) + }) + + it('Should not be able to get the user information', async function () { + await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not be able to upload a video', async function () { + await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should be able to login again', async function () { + const body = await server.login.login() + server.accessToken = body.access_token + server.refreshToken = body.refresh_token + }) + + it('Should be able to get my user information again', async function () { + await server.users.getMyInfo() + }) + + it('Should have an expired access token', async function () { + this.timeout(60000) + + await sqlCommand.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString()) + await sqlCommand.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString()) + + await killallServers([ server ]) + await server.run() + + await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not be able to refresh an access token with an expired refresh token', async function () { + await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should refresh the token', async function () { + this.timeout(50000) + + const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString() + await sqlCommand.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate) + + await killallServers([ server ]) + await server.run() + + const res = await server.login.refreshToken({ refreshToken: server.refreshToken }) + server.accessToken = res.body.access_token + server.refreshToken = res.body.refresh_token + }) + + it('Should be able to get my user information again', async function () { + await server.users.getMyInfo() + }) + }) + + describe('Custom token lifetime', function () { + before(async function () { + this.timeout(120_000) + + await server.kill() + await server.run({ + oauth2: { + token_lifetime: { + access_token: '2 seconds', + refresh_token: '2 seconds' + } + } + }) + }) + + it('Should have a very short access token lifetime', async function () { + this.timeout(50000) + + const { access_token: accessToken } = await server.login.login() + await server.users.getMyInfo({ token: accessToken }) + + await wait(3000) + await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should have a very short refresh token lifetime', async function () { + this.timeout(50000) + + const { refresh_token: refreshToken } = await server.login.login() + await server.login.refreshToken({ refreshToken }) + + await wait(3000) + await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + }) + + after(async function () { + await sqlCommand.cleanup() + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/users/registrations.ts b/packages/tests/src/api/users/registrations.ts new file mode 100644 index 000000000..dbe1bc4f5 --- /dev/null +++ b/packages/tests/src/api/users/registrations.ts @@ -0,0 +1,415 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' +import { UserRegistrationState, UserRole } from '@peertube/peertube-models' +import { + cleanupTests, + ConfigCommand, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test registrations', function () { + let server: PeerTubeServer + + const emails: object[] = [] + let emailPort: number + + before(async function () { + this.timeout(30000) + + emailPort = await MockSmtpServer.Instance.collectEmails(emails) + + server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort)) + + await setAccessTokensToServers([ server ]) + await server.config.enableSignup(false) + }) + + describe('Direct registrations of a new user', function () { + let user1Token: string + + it('Should register a new user', async function () { + const user = { displayName: 'super user 1', username: 'user_1', password: 'my super password' } + const channel = { name: 'my_user_1_channel', displayName: 'my channel rocks' } + + await server.registrations.register({ ...user, channel }) + }) + + it('Should be able to login with this registered user', async function () { + const user1 = { username: 'user_1', password: 'my super password' } + + user1Token = await server.login.getAccessToken(user1) + }) + + it('Should have the correct display name', async function () { + const user = await server.users.getMyInfo({ token: user1Token }) + expect(user.account.displayName).to.equal('super user 1') + }) + + it('Should have the correct video quota', async function () { + const user = await server.users.getMyInfo({ token: user1Token }) + expect(user.videoQuota).to.equal(5 * 1024 * 1024) + }) + + it('Should have created the channel', async function () { + const { displayName } = await server.channels.get({ channelName: 'my_user_1_channel' }) + + expect(displayName).to.equal('my channel rocks') + }) + + it('Should remove me', async function () { + { + const { data } = await server.users.list() + expect(data.find(u => u.username === 'user_1')).to.not.be.undefined + } + + await server.users.deleteMe({ token: user1Token }) + + { + const { data } = await server.users.list() + expect(data.find(u => u.username === 'user_1')).to.be.undefined + } + }) + }) + + describe('Registration requests', function () { + let id2: number + let id3: number + let id4: number + + let user2Token: string + let user3Token: string + + before(async function () { + this.timeout(60000) + + await server.config.enableSignup(true) + + { + const { id } = await server.registrations.requestRegistration({ + username: 'user4', + registrationReason: 'registration reason 4' + }) + + id4 = id + } + }) + + it('Should request a registration without a channel', async function () { + { + const { id } = await server.registrations.requestRegistration({ + username: 'user2', + displayName: 'my super user 2', + email: 'user2@example.com', + password: 'user2password', + registrationReason: 'registration reason 2' + }) + + id2 = id + } + }) + + it('Should request a registration with a channel', async function () { + const { id } = await server.registrations.requestRegistration({ + username: 'user3', + displayName: 'my super user 3', + channel: { + displayName: 'my user 3 channel', + name: 'super_user3_channel' + }, + email: 'user3@example.com', + password: 'user3password', + registrationReason: 'registration reason 3' + }) + + id3 = id + }) + + it('Should list these registration requests', async function () { + { + const { total, data } = await server.registrations.list({ sort: '-createdAt' }) + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + + { + expect(data[0].id).to.equal(id3) + expect(data[0].username).to.equal('user3') + expect(data[0].accountDisplayName).to.equal('my super user 3') + + expect(data[0].channelDisplayName).to.equal('my user 3 channel') + expect(data[0].channelHandle).to.equal('super_user3_channel') + + expect(data[0].createdAt).to.exist + expect(data[0].updatedAt).to.exist + + expect(data[0].email).to.equal('user3@example.com') + expect(data[0].emailVerified).to.be.null + + expect(data[0].moderationResponse).to.be.null + expect(data[0].registrationReason).to.equal('registration reason 3') + expect(data[0].state.id).to.equal(UserRegistrationState.PENDING) + expect(data[0].state.label).to.equal('Pending') + expect(data[0].user).to.be.null + } + + { + expect(data[1].id).to.equal(id2) + expect(data[1].username).to.equal('user2') + expect(data[1].accountDisplayName).to.equal('my super user 2') + + expect(data[1].channelDisplayName).to.be.null + expect(data[1].channelHandle).to.be.null + + expect(data[1].createdAt).to.exist + expect(data[1].updatedAt).to.exist + + expect(data[1].email).to.equal('user2@example.com') + expect(data[1].emailVerified).to.be.null + + expect(data[1].moderationResponse).to.be.null + expect(data[1].registrationReason).to.equal('registration reason 2') + expect(data[1].state.id).to.equal(UserRegistrationState.PENDING) + expect(data[1].state.label).to.equal('Pending') + expect(data[1].user).to.be.null + } + + { + expect(data[2].username).to.equal('user4') + } + } + + { + const { total, data } = await server.registrations.list({ count: 1, start: 1, sort: 'createdAt' }) + + expect(total).to.equal(3) + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.equal(id2) + } + + { + const { total, data } = await server.registrations.list({ search: 'user3' }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.equal(id3) + } + }) + + it('Should reject a registration request', async function () { + await server.registrations.reject({ id: id4, moderationResponse: 'I do not want id 4 on this instance' }) + }) + + it('Should have sent an email to the user explanining the registration has been rejected', async function () { + this.timeout(50000) + + await waitJobs([ server ]) + + const email = emails.find(e => e['to'][0]['address'] === 'user4@example.com') + expect(email).to.exist + + expect(email['subject']).to.contain('been rejected') + expect(email['text']).to.contain('been rejected') + expect(email['text']).to.contain('I do not want id 4 on this instance') + }) + + it('Should accept registration requests', async function () { + await server.registrations.accept({ id: id2, moderationResponse: 'Welcome id 2' }) + await server.registrations.accept({ id: id3, moderationResponse: 'Welcome id 3' }) + }) + + it('Should have sent an email to the user explanining the registration has been accepted', async function () { + this.timeout(50000) + + await waitJobs([ server ]) + + { + const email = emails.find(e => e['to'][0]['address'] === 'user2@example.com') + expect(email).to.exist + + expect(email['subject']).to.contain('been accepted') + expect(email['text']).to.contain('been accepted') + expect(email['text']).to.contain('Welcome id 2') + } + + { + const email = emails.find(e => e['to'][0]['address'] === 'user3@example.com') + expect(email).to.exist + + expect(email['subject']).to.contain('been accepted') + expect(email['text']).to.contain('been accepted') + expect(email['text']).to.contain('Welcome id 3') + } + }) + + it('Should login with these users', async function () { + user2Token = await server.login.getAccessToken({ username: 'user2', password: 'user2password' }) + user3Token = await server.login.getAccessToken({ username: 'user3', password: 'user3password' }) + }) + + it('Should have created the appropriate attributes for user 2', async function () { + const me = await server.users.getMyInfo({ token: user2Token }) + + expect(me.username).to.equal('user2') + expect(me.account.displayName).to.equal('my super user 2') + expect(me.videoQuota).to.equal(5 * 1024 * 1024) + expect(me.videoChannels[0].name).to.equal('user2_channel') + expect(me.videoChannels[0].displayName).to.equal('Main user2 channel') + expect(me.role.id).to.equal(UserRole.USER) + expect(me.email).to.equal('user2@example.com') + }) + + it('Should have created the appropriate attributes for user 3', async function () { + const me = await server.users.getMyInfo({ token: user3Token }) + + expect(me.username).to.equal('user3') + expect(me.account.displayName).to.equal('my super user 3') + expect(me.videoQuota).to.equal(5 * 1024 * 1024) + expect(me.videoChannels[0].name).to.equal('super_user3_channel') + expect(me.videoChannels[0].displayName).to.equal('my user 3 channel') + expect(me.role.id).to.equal(UserRole.USER) + expect(me.email).to.equal('user3@example.com') + }) + + it('Should list these accepted/rejected registration requests', async function () { + const { data } = await server.registrations.list({ sort: 'createdAt' }) + const { data: users } = await server.users.list() + + { + expect(data[0].id).to.equal(id4) + expect(data[0].state.id).to.equal(UserRegistrationState.REJECTED) + expect(data[0].state.label).to.equal('Rejected') + + expect(data[0].moderationResponse).to.equal('I do not want id 4 on this instance') + expect(data[0].user).to.be.null + + expect(users.find(u => u.username === 'user4')).to.not.exist + } + + { + expect(data[1].id).to.equal(id2) + expect(data[1].state.id).to.equal(UserRegistrationState.ACCEPTED) + expect(data[1].state.label).to.equal('Accepted') + + expect(data[1].moderationResponse).to.equal('Welcome id 2') + expect(data[1].user).to.exist + + const user2 = users.find(u => u.username === 'user2') + expect(data[1].user.id).to.equal(user2.id) + } + + { + expect(data[2].id).to.equal(id3) + expect(data[2].state.id).to.equal(UserRegistrationState.ACCEPTED) + expect(data[2].state.label).to.equal('Accepted') + + expect(data[2].moderationResponse).to.equal('Welcome id 3') + expect(data[2].user).to.exist + + const user3 = users.find(u => u.username === 'user3') + expect(data[2].user.id).to.equal(user3.id) + } + }) + + it('Shoulde delete a registration', async function () { + await server.registrations.delete({ id: id2 }) + await server.registrations.delete({ id: id3 }) + + const { total, data } = await server.registrations.list() + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.equal(id4) + + const { data: users } = await server.users.list() + + for (const username of [ 'user2', 'user3' ]) { + expect(users.find(u => u.username === username)).to.exist + } + }) + + it('Should be able to prevent email delivery on accept/reject', async function () { + this.timeout(50000) + + let id1: number + let id2: number + + { + const { id } = await server.registrations.requestRegistration({ + username: 'user7', + email: 'user7@example.com', + registrationReason: 'tt' + }) + id1 = id + } + { + const { id } = await server.registrations.requestRegistration({ + username: 'user8', + email: 'user8@example.com', + registrationReason: 'tt' + }) + id2 = id + } + + await server.registrations.accept({ id: id1, moderationResponse: 'tt', preventEmailDelivery: true }) + await server.registrations.reject({ id: id2, moderationResponse: 'tt', preventEmailDelivery: true }) + + await waitJobs([ server ]) + + const filtered = emails.filter(e => { + const address = e['to'][0]['address'] + return address === 'user7@example.com' || address === 'user8@example.com' + }) + + expect(filtered).to.have.lengthOf(0) + }) + + it('Should request a registration without a channel, that will conflict with an already existing channel', async function () { + let id1: number + let id2: number + + { + const { id } = await server.registrations.requestRegistration({ + registrationReason: 'tt', + username: 'user5', + password: 'user5password', + channel: { + displayName: 'channel 6', + name: 'user6_channel' + } + }) + + id1 = id + } + + { + const { id } = await server.registrations.requestRegistration({ + registrationReason: 'tt', + username: 'user6', + password: 'user6password' + }) + + id2 = id + } + + await server.registrations.accept({ id: id1, moderationResponse: 'tt' }) + await server.registrations.accept({ id: id2, moderationResponse: 'tt' }) + + const user5Token = await server.login.getAccessToken('user5', 'user5password') + const user6Token = await server.login.getAccessToken('user6', 'user6password') + + const user5 = await server.users.getMyInfo({ token: user5Token }) + const user6 = await server.users.getMyInfo({ token: user6Token }) + + expect(user5.videoChannels[0].name).to.equal('user6_channel') + expect(user6.videoChannels[0].name).to.equal('user6_channel-1') + }) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/users/two-factor.ts b/packages/tests/src/api/users/two-factor.ts new file mode 100644 index 000000000..fda125d20 --- /dev/null +++ b/packages/tests/src/api/users/two-factor.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode, HttpStatusCodeType } from '@peertube/peertube-models' +import { expectStartWith } from '@tests/shared/checks.js' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + TwoFactorCommand +} from '@peertube/peertube-server-commands' + +async function login (options: { + server: PeerTubeServer + username: string + password: string + otpToken?: string + expectedStatus?: HttpStatusCodeType +}) { + const { server, username, password, otpToken, expectedStatus } = options + + const user = { username, password } + const { res, body: { access_token: token } } = await server.login.loginAndGetResponse({ user, otpToken, expectedStatus }) + + return { res, token } +} + +describe('Test users', function () { + let server: PeerTubeServer + let otpSecret: string + let requestToken: string + + const userUsername = 'user1' + let userId: number + let userPassword: string + let userToken: string + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + const res = await server.users.generate(userUsername) + userId = res.userId + userPassword = res.password + userToken = res.token + }) + + it('Should not add the header on login if two factor is not enabled', async function () { + const { res, token } = await login({ server, username: userUsername, password: userPassword }) + + expect(res.header['x-peertube-otp']).to.not.exist + + await server.users.getMyInfo({ token }) + }) + + it('Should request two factor and get the secret and uri', async function () { + const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword }) + + expect(otpRequest.requestToken).to.exist + + expect(otpRequest.secret).to.exist + expect(otpRequest.secret).to.have.lengthOf(32) + + expect(otpRequest.uri).to.exist + expectStartWith(otpRequest.uri, 'otpauth://') + expect(otpRequest.uri).to.include(otpRequest.secret) + + requestToken = otpRequest.requestToken + otpSecret = otpRequest.secret + }) + + it('Should not have two factor confirmed yet', async function () { + const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken }) + expect(twoFactorEnabled).to.be.false + }) + + it('Should confirm two factor', async function () { + await server.twoFactor.confirmRequest({ + userId, + token: userToken, + otpToken: TwoFactorCommand.buildOTP({ secret: otpSecret }).generate(), + requestToken + }) + }) + + it('Should not add the header on login if two factor is enabled and password is incorrect', async function () { + const { res, token } = await login({ server, username: userUsername, password: 'fake', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.not.exist + }) + + it('Should add the header on login if two factor is enabled and password is correct', async function () { + const { res, token } = await login({ + server, + username: userUsername, + password: userPassword, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + + expect(res.header['x-peertube-otp']).to.exist + expect(token).to.not.exist + + await server.users.getMyInfo({ token }) + }) + + it('Should not login with correct password and incorrect otp secret', async function () { + const otp = TwoFactorCommand.buildOTP({ secret: 'a'.repeat(32) }) + + const { res, token } = await login({ + server, + username: userUsername, + password: userPassword, + otpToken: otp.generate(), + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.not.exist + }) + + it('Should not login with correct password and incorrect otp code', async function () { + const { res, token } = await login({ + server, + username: userUsername, + password: userPassword, + otpToken: '123456', + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.not.exist + }) + + it('Should not login with incorrect password and correct otp code', async function () { + const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate() + + const { res, token } = await login({ + server, + username: userUsername, + password: 'fake', + otpToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.not.exist + }) + + it('Should correctly login with correct password and otp code', async function () { + const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate() + + const { res, token } = await login({ server, username: userUsername, password: userPassword, otpToken }) + + expect(res.header['x-peertube-otp']).to.not.exist + expect(token).to.exist + + await server.users.getMyInfo({ token }) + }) + + it('Should have two factor enabled when getting my info', async function () { + const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken }) + expect(twoFactorEnabled).to.be.true + }) + + it('Should disable two factor and be able to login without otp token', async function () { + await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword }) + + const { res, token } = await login({ server, username: userUsername, password: userPassword }) + expect(res.header['x-peertube-otp']).to.not.exist + + await server.users.getMyInfo({ token }) + }) + + it('Should have two factor disabled when getting my info', async function () { + const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken }) + expect(twoFactorEnabled).to.be.false + }) + + it('Should enable two factor auth without password from an admin', async function () { + const { otpRequest } = await server.twoFactor.request({ userId }) + + await server.twoFactor.confirmRequest({ + userId, + otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate(), + requestToken: otpRequest.requestToken + }) + + const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken }) + expect(twoFactorEnabled).to.be.true + }) + + it('Should disable two factor auth without password from an admin', async function () { + await server.twoFactor.disable({ userId }) + + const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken }) + expect(twoFactorEnabled).to.be.false + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/users/user-subscriptions.ts b/packages/tests/src/api/users/user-subscriptions.ts new file mode 100644 index 000000000..eb4ea9539 --- /dev/null +++ b/packages/tests/src/api/users/user-subscriptions.ts @@ -0,0 +1,614 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + SubscriptionsCommand, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test users subscriptions', function () { + let servers: PeerTubeServer[] = [] + const users: { accessToken: string }[] = [] + let video3UUID: string + + let command: SubscriptionsCommand + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultChannelAvatar(servers) + await setDefaultAccountAvatar(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + for (const server of servers) { + const user = { username: 'user' + server.serverNumber, password: 'password' } + await server.users.create({ username: user.username, password: user.password }) + + const accessToken = await server.login.getAccessToken(user) + users.push({ accessToken }) + + const videoName1 = 'video 1-' + server.serverNumber + await server.videos.upload({ token: accessToken, attributes: { name: videoName1 } }) + + const videoName2 = 'video 2-' + server.serverNumber + await server.videos.upload({ token: accessToken, attributes: { name: videoName2 } }) + } + + await waitJobs(servers) + + command = servers[0].subscriptions + }) + + describe('Destinction between server videos and user videos', function () { + it('Should display videos of server 2 on server 1', async function () { + const { total } = await servers[0].videos.list() + + expect(total).to.equal(4) + }) + + it('User of server 1 should follow user of server 3 and root of server 1', async function () { + this.timeout(60000) + + await command.add({ token: users[0].accessToken, targetUri: 'user3_channel@' + servers[2].host }) + await command.add({ token: users[0].accessToken, targetUri: 'root_channel@' + servers[0].host }) + + await waitJobs(servers) + + const attributes = { name: 'video server 3 added after follow' } + const { uuid } = await servers[2].videos.upload({ token: users[2].accessToken, attributes }) + video3UUID = uuid + + await waitJobs(servers) + }) + + it('Should not display videos of server 3 on server 1', async function () { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(4) + + for (const video of data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow') + } + }) + }) + + describe('Subscription endpoints', function () { + + it('Should list subscriptions', async function () { + { + const body = await command.list() + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + + { + const body = await command.list({ token: users[0].accessToken, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const subscriptions = body.data + expect(subscriptions).to.be.an('array') + expect(subscriptions).to.have.lengthOf(2) + + expect(subscriptions[0].name).to.equal('user3_channel') + expect(subscriptions[1].name).to.equal('root_channel') + } + }) + + it('Should get subscription', async function () { + { + const videoChannel = await command.get({ token: users[0].accessToken, uri: 'user3_channel@' + servers[2].host }) + + expect(videoChannel.name).to.equal('user3_channel') + expect(videoChannel.host).to.equal(servers[2].host) + expect(videoChannel.displayName).to.equal('Main user3 channel') + expect(videoChannel.followingCount).to.equal(0) + expect(videoChannel.followersCount).to.equal(1) + } + + { + const videoChannel = await command.get({ token: users[0].accessToken, uri: 'root_channel@' + servers[0].host }) + + expect(videoChannel.name).to.equal('root_channel') + expect(videoChannel.host).to.equal(servers[0].host) + expect(videoChannel.displayName).to.equal('Main root channel') + expect(videoChannel.followingCount).to.equal(0) + expect(videoChannel.followersCount).to.equal(1) + } + }) + + it('Should return the existing subscriptions', async function () { + const uris = [ + 'user3_channel@' + servers[2].host, + 'root2_channel@' + servers[0].host, + 'root_channel@' + servers[0].host, + 'user3_channel@' + servers[0].host + ] + + const body = await command.exist({ token: users[0].accessToken, uris }) + + expect(body['user3_channel@' + servers[2].host]).to.be.true + expect(body['root2_channel@' + servers[0].host]).to.be.false + expect(body['root_channel@' + servers[0].host]).to.be.true + expect(body['user3_channel@' + servers[0].host]).to.be.false + }) + + it('Should search among subscriptions', async function () { + { + const body = await command.list({ token: users[0].accessToken, sort: '-createdAt', search: 'user3_channel' }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } + + { + const body = await command.list({ token: users[0].accessToken, sort: '-createdAt', search: 'toto' }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + }) + }) + + describe('Subscription videos', function () { + + it('Should list subscription videos', async function () { + { + const body = await servers[0].videos.listMySubscriptionVideos() + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + + { + const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' }) + expect(body.total).to.equal(3) + + const videos = body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(3) + + expect(videos[0].name).to.equal('video 1-3') + expect(videos[1].name).to.equal('video 2-3') + expect(videos[2].name).to.equal('video server 3 added after follow') + } + + { + const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, count: 1, start: 1 }) + expect(body.total).to.equal(3) + + const videos = body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(1) + + expect(videos[0].name).to.equal('video 2-3') + } + }) + + it('Should upload a video by root on server 1 and see it in the subscription videos', async function () { + this.timeout(60000) + + const videoName = 'video server 1 added after follow' + await servers[0].videos.upload({ attributes: { name: videoName } }) + + await waitJobs(servers) + + { + const body = await servers[0].videos.listMySubscriptionVideos() + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + + { + const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' }) + expect(body.total).to.equal(4) + + const videos = body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(4) + + expect(videos[0].name).to.equal('video 1-3') + expect(videos[1].name).to.equal('video 2-3') + expect(videos[2].name).to.equal('video server 3 added after follow') + expect(videos[3].name).to.equal('video server 1 added after follow') + } + + { + const { data, total } = await servers[0].videos.list() + expect(total).to.equal(5) + + for (const video of data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow') + } + } + }) + + it('Should have server 1 following server 3 and display server 3 videos', async function () { + this.timeout(60000) + + await servers[0].follows.follow({ hosts: [ servers[2].url ] }) + + await waitJobs(servers) + + const { data, total } = await servers[0].videos.list() + expect(total).to.equal(8) + + const names = [ '1-3', '2-3', 'video server 3 added after follow' ] + for (const name of names) { + const video = data.find(v => v.name.includes(name)) + expect(video).to.not.be.undefined + } + }) + + it('Should remove follow server 1 -> server 3 and hide server 3 videos', async function () { + this.timeout(60000) + + await servers[0].follows.unfollow({ target: servers[2] }) + + await waitJobs(servers) + + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(5) + + for (const video of data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow') + } + }) + + it('Should still list subscription videos', async function () { + { + const body = await servers[0].videos.listMySubscriptionVideos() + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + } + + { + const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' }) + expect(body.total).to.equal(4) + + const videos = body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(4) + + expect(videos[0].name).to.equal('video 1-3') + expect(videos[1].name).to.equal('video 2-3') + expect(videos[2].name).to.equal('video server 3 added after follow') + expect(videos[3].name).to.equal('video server 1 added after follow') + } + }) + }) + + describe('Existing subscription video update', function () { + + it('Should update a video of server 3 and see the updated video on server 1', async function () { + this.timeout(30000) + + await servers[2].videos.update({ id: video3UUID, attributes: { name: 'video server 3 added after follow updated' } }) + + await waitJobs(servers) + + const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' }) + expect(body.data[2].name).to.equal('video server 3 added after follow updated') + }) + }) + + describe('Subscription removal', function () { + + it('Should remove user of server 3 subscription', async function () { + this.timeout(30000) + + await command.remove({ token: users[0].accessToken, uri: 'user3_channel@' + servers[2].host }) + + await waitJobs(servers) + }) + + it('Should not display its videos anymore', async function () { + const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' }) + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(1) + + expect(videos[0].name).to.equal('video server 1 added after follow') + }) + + it('Should remove the root subscription and not display the videos anymore', async function () { + this.timeout(30000) + + await command.remove({ token: users[0].accessToken, uri: 'root_channel@' + servers[0].host }) + + await waitJobs(servers) + + { + const body = await command.list({ token: users[0].accessToken, sort: 'createdAt' }) + expect(body.total).to.equal(0) + + const videos = body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(0) + } + }) + + it('Should correctly display public videos on server 1', async function () { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(5) + + for (const video of data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow updated') + } + }) + }) + + describe('Re-follow', function () { + + it('Should follow user of server 3 again', async function () { + this.timeout(60000) + + await command.add({ token: users[0].accessToken, targetUri: 'user3_channel@' + servers[2].host }) + + await waitJobs(servers) + + { + const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' }) + expect(body.total).to.equal(3) + + const videos = body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(3) + + expect(videos[0].name).to.equal('video 1-3') + expect(videos[1].name).to.equal('video 2-3') + expect(videos[2].name).to.equal('video server 3 added after follow updated') + } + + { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(5) + + for (const video of data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow updated') + } + } + }) + + it('Should follow user channels of server 3 by root of server 3', async function () { + this.timeout(60000) + + await servers[2].channels.create({ token: users[2].accessToken, attributes: { name: 'user3_channel2' } }) + + await servers[2].subscriptions.add({ token: servers[2].accessToken, targetUri: 'user3_channel@' + servers[2].host }) + await servers[2].subscriptions.add({ token: servers[2].accessToken, targetUri: 'user3_channel2@' + servers[2].host }) + + await waitJobs(servers) + }) + }) + + describe('Followers listing', function () { + + it('Should list user 3 followers', async function () { + { + const { total, data } = await servers[2].accounts.listFollowers({ + token: users[2].accessToken, + accountName: 'user3', + start: 0, + count: 5, + sort: 'createdAt' + }) + + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('user1') + + expect(data[1].following.host).to.equal(servers[2].host) + expect(data[1].following.name).to.equal('user3_channel') + expect(data[1].follower.host).to.equal(servers[2].host) + expect(data[1].follower.name).to.equal('root') + + expect(data[2].following.host).to.equal(servers[2].host) + expect(data[2].following.name).to.equal('user3_channel2') + expect(data[2].follower.host).to.equal(servers[2].host) + expect(data[2].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].accounts.listFollowers({ + token: users[2].accessToken, + accountName: 'user3', + start: 0, + count: 1, + sort: '-createdAt' + }) + + expect(total).to.equal(3) + expect(data).to.have.lengthOf(1) + + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel2') + expect(data[0].follower.host).to.equal(servers[2].host) + expect(data[0].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].accounts.listFollowers({ + token: users[2].accessToken, + accountName: 'user3', + start: 1, + count: 1, + sort: '-createdAt' + }) + + expect(total).to.equal(3) + expect(data).to.have.lengthOf(1) + + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[2].host) + expect(data[0].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].accounts.listFollowers({ + token: users[2].accessToken, + accountName: 'user3', + search: 'user1', + sort: '-createdAt' + }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('user1') + } + }) + + it('Should list user3_channel followers', async function () { + { + const { total, data } = await servers[2].channels.listFollowers({ + token: users[2].accessToken, + channelName: 'user3_channel', + start: 0, + count: 5, + sort: 'createdAt' + }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('user1') + + expect(data[1].following.host).to.equal(servers[2].host) + expect(data[1].following.name).to.equal('user3_channel') + expect(data[1].follower.host).to.equal(servers[2].host) + expect(data[1].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].channels.listFollowers({ + token: users[2].accessToken, + channelName: 'user3_channel', + start: 0, + count: 1, + sort: '-createdAt' + }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(1) + + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[2].host) + expect(data[0].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].channels.listFollowers({ + token: users[2].accessToken, + channelName: 'user3_channel', + start: 1, + count: 1, + sort: '-createdAt' + }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(1) + + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('user1') + } + + { + const { total, data } = await servers[2].channels.listFollowers({ + token: users[2].accessToken, + channelName: 'user3_channel', + search: 'user1', + sort: '-createdAt' + }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('user1') + } + }) + }) + + describe('Subscription videos privacy', function () { + + it('Should update video as internal and not see from remote server', async function () { + this.timeout(30000) + + await servers[2].videos.update({ id: video3UUID, attributes: { name: 'internal', privacy: VideoPrivacy.INTERNAL } }) + await waitJobs(servers) + + { + const { data } = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken }) + expect(data.find(v => v.name === 'internal')).to.not.exist + } + }) + + it('Should see internal from local user', async function () { + const { data } = await servers[2].videos.listMySubscriptionVideos({ token: servers[2].accessToken }) + expect(data.find(v => v.name === 'internal')).to.exist + }) + + it('Should update video as private and not see from anyone server', async function () { + this.timeout(30000) + + await servers[2].videos.update({ id: video3UUID, attributes: { name: 'private', privacy: VideoPrivacy.PRIVATE } }) + await waitJobs(servers) + + { + const { data } = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken }) + expect(data.find(v => v.name === 'private')).to.not.exist + } + + { + const { data } = await servers[2].videos.listMySubscriptionVideos({ token: servers[2].accessToken }) + expect(data.find(v => v.name === 'private')).to.not.exist + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/users/user-videos.ts b/packages/tests/src/api/users/user-videos.ts new file mode 100644 index 000000000..7b075d040 --- /dev/null +++ b/packages/tests/src/api/users/user-videos.ts @@ -0,0 +1,219 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test user videos', function () { + let server: PeerTubeServer + let videoId: number + let videoId2: number + let token: string + let anotherUserToken: string + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultChannelAvatar([ server ]) + await setDefaultAccountAvatar([ server ]) + + await server.videos.quickUpload({ name: 'root video' }) + await server.videos.quickUpload({ name: 'root video 2' }) + + token = await server.users.generateUserAndToken('user') + anotherUserToken = await server.users.generateUserAndToken('user2') + }) + + describe('List my videos', function () { + + it('Should list my videos', async function () { + const { data, total } = await server.videos.listMyVideos() + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + }) + }) + + describe('Upload', function () { + + it('Should upload the video with the correct token', async function () { + await server.videos.upload({ token }) + const { data } = await server.videos.list() + const video = data[0] + + expect(video.account.name).to.equal('user') + videoId = video.id + }) + + it('Should upload the video again with the correct token', async function () { + const { id } = await server.videos.upload({ token }) + videoId2 = id + }) + }) + + describe('Ratings', function () { + + it('Should retrieve a video rating', async function () { + await server.videos.rate({ id: videoId, token, rating: 'like' }) + const rating = await server.users.getMyRating({ token, videoId }) + + expect(rating.videoId).to.equal(videoId) + expect(rating.rating).to.equal('like') + }) + + it('Should retrieve ratings list', async function () { + await server.videos.rate({ id: videoId, token, rating: 'like' }) + + const body = await server.accounts.listRatings({ accountName: 'user', token }) + + expect(body.total).to.equal(1) + expect(body.data[0].video.id).to.equal(videoId) + expect(body.data[0].rating).to.equal('like') + }) + + it('Should retrieve ratings list by rating type', async function () { + { + const body = await server.accounts.listRatings({ accountName: 'user', token, rating: 'like' }) + expect(body.data.length).to.equal(1) + } + + { + const body = await server.accounts.listRatings({ accountName: 'user', token, rating: 'dislike' }) + expect(body.data.length).to.equal(0) + } + }) + }) + + describe('Remove video', function () { + + it('Should not be able to remove the video with an incorrect token', async function () { + await server.videos.remove({ token: 'bad_token', id: videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not be able to remove the video with the token of another account', async function () { + await server.videos.remove({ token: anotherUserToken, id: videoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should be able to remove the video with the correct token', async function () { + await server.videos.remove({ token, id: videoId }) + await server.videos.remove({ token, id: videoId2 }) + }) + }) + + describe('My videos & quotas', function () { + + it('Should be able to upload a video with a user', async function () { + this.timeout(30000) + + const attributes = { + name: 'super user video', + fixture: 'video_short.webm' + } + await server.videos.upload({ token, attributes }) + + await server.channels.create({ token, attributes: { name: 'other_channel' } }) + }) + + it('Should have video quota updated', async function () { + const quota = await server.users.getMyQuotaUsed({ token }) + expect(quota.videoQuotaUsed).to.equal(218910) + expect(quota.videoQuotaUsedDaily).to.equal(218910) + + const { data } = await server.users.list() + const tmpUser = data.find(u => u.username === 'user') + expect(tmpUser.videoQuotaUsed).to.equal(218910) + expect(tmpUser.videoQuotaUsedDaily).to.equal(218910) + }) + + it('Should be able to list my videos', async function () { + const { total, data } = await server.videos.listMyVideos({ token }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const video = data[0] + expect(video.name).to.equal('super user video') + expect(video.thumbnailPath).to.not.be.null + expect(video.previewPath).to.not.be.null + }) + + it('Should be able to filter by channel in my videos', async function () { + const myInfo = await server.users.getMyInfo({ token }) + const mainChannel = myInfo.videoChannels.find(c => c.name !== 'other_channel') + const otherChannel = myInfo.videoChannels.find(c => c.name === 'other_channel') + + { + const { total, data } = await server.videos.listMyVideos({ token, channelId: mainChannel.id }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const video = data[0] + expect(video.name).to.equal('super user video') + expect(video.thumbnailPath).to.not.be.null + expect(video.previewPath).to.not.be.null + } + + { + const { total, data } = await server.videos.listMyVideos({ token, channelId: otherChannel.id }) + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + }) + + it('Should be able to search in my videos', async function () { + { + const { total, data } = await server.videos.listMyVideos({ token, sort: '-createdAt', search: 'user video' }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + } + + { + const { total, data } = await server.videos.listMyVideos({ token, sort: '-createdAt', search: 'toto' }) + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + }) + + it('Should disable web videos, enable HLS, and update my quota', async function () { + this.timeout(160000) + + { + const config = await server.config.getCustomConfig() + config.transcoding.webVideos.enabled = false + config.transcoding.hls.enabled = true + config.transcoding.enabled = true + await server.config.updateCustomSubConfig({ newConfig: config }) + } + + { + const attributes = { + name: 'super user video 2', + fixture: 'video_short.webm' + } + await server.videos.upload({ token, attributes }) + + await waitJobs([ server ]) + } + + { + const data = await server.users.getMyQuotaUsed({ token }) + expect(data.videoQuotaUsed).to.be.greaterThan(220000) + expect(data.videoQuotaUsedDaily).to.be.greaterThan(220000) + } + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/users/users-email-verification.ts b/packages/tests/src/api/users/users-email-verification.ts new file mode 100644 index 000000000..689e3c4bb --- /dev/null +++ b/packages/tests/src/api/users/users-email-verification.ts @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + ConfigCommand, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test users email verification', function () { + let server: PeerTubeServer + let userId: number + let userAccessToken: string + let verificationString: string + let expectedEmailsLength = 0 + const user1 = { + username: 'user_1', + password: 'super password' + } + const user2 = { + username: 'user_2', + password: 'super password' + } + const emails: object[] = [] + + before(async function () { + this.timeout(30000) + + const port = await MockSmtpServer.Instance.collectEmails(emails) + server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port)) + + await setAccessTokensToServers([ server ]) + }) + + it('Should register user and send verification email if verification required', async function () { + this.timeout(30000) + + await server.config.updateExistingSubConfig({ + newConfig: { + signup: { + enabled: true, + requiresApproval: false, + requiresEmailVerification: true, + limit: 10 + } + } + }) + + await server.registrations.register(user1) + + await waitJobs(server) + expectedEmailsLength++ + expect(emails).to.have.lengthOf(expectedEmailsLength) + + const email = emails[expectedEmailsLength - 1] + + const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text']) + expect(verificationStringMatches).not.to.be.null + + verificationString = verificationStringMatches[1] + expect(verificationString).to.have.length.above(2) + + const userIdMatches = /userId=([0-9]+)/.exec(email['text']) + expect(userIdMatches).not.to.be.null + + userId = parseInt(userIdMatches[1], 10) + + const body = await server.users.get({ userId }) + expect(body.emailVerified).to.be.false + }) + + it('Should not allow login for user with unverified email', async function () { + const { detail } = await server.login.login({ user: user1, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + expect(detail).to.contain('User email is not verified.') + }) + + it('Should verify the user via email and allow login', async function () { + await server.users.verifyEmail({ userId, verificationString }) + + const body = await server.login.login({ user: user1 }) + userAccessToken = body.access_token + + const user = await server.users.get({ userId }) + expect(user.emailVerified).to.be.true + }) + + it('Should be able to change the user email', async function () { + let updateVerificationString: string + + { + await server.users.updateMe({ + token: userAccessToken, + email: 'updated@example.com', + currentPassword: user1.password + }) + + await waitJobs(server) + expectedEmailsLength++ + expect(emails).to.have.lengthOf(expectedEmailsLength) + + const email = emails[expectedEmailsLength - 1] + + const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text']) + updateVerificationString = verificationStringMatches[1] + } + + { + const me = await server.users.getMyInfo({ token: userAccessToken }) + expect(me.email).to.equal('user_1@example.com') + expect(me.pendingEmail).to.equal('updated@example.com') + } + + { + await server.users.verifyEmail({ userId, verificationString: updateVerificationString, isPendingEmail: true }) + + const me = await server.users.getMyInfo({ token: userAccessToken }) + expect(me.email).to.equal('updated@example.com') + expect(me.pendingEmail).to.be.null + } + }) + + it('Should register user not requiring email verification if setting not enabled', async function () { + this.timeout(5000) + await server.config.updateExistingSubConfig({ + newConfig: { + signup: { + requiresEmailVerification: false + } + } + }) + + await server.registrations.register(user2) + + await waitJobs(server) + expect(emails).to.have.lengthOf(expectedEmailsLength) + + const accessToken = await server.login.getAccessToken(user2) + + const user = await server.users.getMyInfo({ token: accessToken }) + expect(user.emailVerified).to.be.null + }) + + it('Should allow login for user with unverified email when setting later enabled', async function () { + await server.config.updateCustomSubConfig({ + newConfig: { + signup: { + requiresEmailVerification: true + } + } + }) + + await server.login.getAccessToken(user2) + }) + + after(async function () { + MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/users/users-multiple-servers.ts b/packages/tests/src/api/users/users-multiple-servers.ts new file mode 100644 index 000000000..61e3aa001 --- /dev/null +++ b/packages/tests/src/api/users/users-multiple-servers.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { MyUser } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultChannelAvatar, + waitJobs +} from '@peertube/peertube-server-commands' +import { checkActorFilesWereRemoved } from '@tests/shared/actors.js' +import { testImage } from '@tests/shared/checks.js' +import { checkTmpIsEmpty } from '@tests/shared/directories.js' +import { saveVideoInServers, checkVideoFilesWereRemoved } from '@tests/shared/videos.js' + +describe('Test users with multiple servers', function () { + let servers: PeerTubeServer[] = [] + + let user: MyUser + let userId: number + + let videoUUID: string + let userAccessToken: string + let userAvatarFilenames: string[] + + before(async function () { + this.timeout(120_000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultChannelAvatar(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + // Server 1 and server 3 follow each other + await doubleFollow(servers[0], servers[2]) + // Server 2 and server 3 follow each other + await doubleFollow(servers[1], servers[2]) + + // The root user of server 1 is propagated to servers 2 and 3 + await servers[0].videos.upload() + + { + const username = 'user1' + const created = await servers[0].users.create({ username }) + userId = created.id + userAccessToken = await servers[0].login.getAccessToken(username) + } + + { + const { uuid } = await servers[0].videos.upload({ token: userAccessToken }) + videoUUID = uuid + + await waitJobs(servers) + + await saveVideoInServers(servers, videoUUID) + } + }) + + it('Should be able to update my display name', async function () { + await servers[0].users.updateMe({ displayName: 'my super display name' }) + + user = await servers[0].users.getMyInfo() + expect(user.account.displayName).to.equal('my super display name') + + await waitJobs(servers) + }) + + it('Should be able to update my description', async function () { + this.timeout(10_000) + + await servers[0].users.updateMe({ description: 'my super description updated' }) + + user = await servers[0].users.getMyInfo() + expect(user.account.displayName).to.equal('my super display name') + expect(user.account.description).to.equal('my super description updated') + + await waitJobs(servers) + }) + + it('Should be able to update my avatar', async function () { + this.timeout(10_000) + + const fixture = 'avatar2.png' + + await servers[0].users.updateMyAvatar({ fixture }) + + user = await servers[0].users.getMyInfo() + userAvatarFilenames = user.account.avatars.map(({ path }) => path) + + for (const avatar of user.account.avatars) { + await testImage(servers[0].url, `avatar2-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') + } + + await waitJobs(servers) + }) + + it('Should have updated my profile on other servers too', async function () { + let createdAt: string | Date + + for (const server of servers) { + const body = await server.accounts.list({ sort: '-createdAt' }) + + const resList = body.data.find(a => a.name === 'root' && a.host === servers[0].host) + expect(resList).not.to.be.undefined + + const account = await server.accounts.get({ accountName: resList.name + '@' + resList.host }) + + if (!createdAt) createdAt = account.createdAt + + expect(account.name).to.equal('root') + expect(account.host).to.equal(servers[0].host) + expect(account.displayName).to.equal('my super display name') + expect(account.description).to.equal('my super description updated') + expect(createdAt).to.equal(account.createdAt) + + if (server.serverNumber === 1) { + expect(account.userId).to.be.a('number') + } else { + expect(account.userId).to.be.undefined + } + + for (const avatar of account.avatars) { + await testImage(server.url, `avatar2-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') + } + } + }) + + it('Should list account videos', async function () { + for (const server of servers) { + const { total, data } = await server.videos.listByAccount({ handle: 'user1@' + servers[0].host }) + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(1) + expect(data[0].uuid).to.equal(videoUUID) + } + }) + + it('Should search through account videos', async function () { + const created = await servers[0].videos.upload({ token: userAccessToken, attributes: { name: 'Kami no chikara' } }) + + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.listByAccount({ handle: 'user1@' + servers[0].host, search: 'Kami' }) + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(1) + expect(data[0].uuid).to.equal(created.uuid) + } + }) + + it('Should remove the user', async function () { + this.timeout(10_000) + + for (const server of servers) { + const body = await server.accounts.list({ sort: '-createdAt' }) + + const accountDeleted = body.data.find(a => a.name === 'user1' && a.host === servers[0].host) + expect(accountDeleted).not.to.be.undefined + + const { data } = await server.channels.list() + const videoChannelDeleted = data.find(a => a.displayName === 'Main user1 channel' && a.host === servers[0].host) + expect(videoChannelDeleted).not.to.be.undefined + } + + await servers[0].users.remove({ userId }) + + await waitJobs(servers) + + for (const server of servers) { + const body = await server.accounts.list({ sort: '-createdAt' }) + + const accountDeleted = body.data.find(a => a.name === 'user1' && a.host === servers[0].host) + expect(accountDeleted).to.be.undefined + + const { data } = await server.channels.list() + const videoChannelDeleted = data.find(a => a.name === 'Main user1 channel' && a.host === servers[0].host) + expect(videoChannelDeleted).to.be.undefined + } + }) + + it('Should not have actor files', async () => { + for (const server of servers) { + for (const userAvatarFilename of userAvatarFilenames) { + await checkActorFilesWereRemoved(userAvatarFilename, server) + } + } + }) + + it('Should not have video files', async () => { + for (const server of servers) { + await checkVideoFilesWereRemoved({ server, video: server.store.videoDetails }) + } + }) + + it('Should have an empty tmp directory', async function () { + for (const server of servers) { + await checkTmpIsEmpty(server) + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/users/users.ts b/packages/tests/src/api/users/users.ts new file mode 100644 index 000000000..a0090a463 --- /dev/null +++ b/packages/tests/src/api/users/users.ts @@ -0,0 +1,529 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { testImageSize } from '@tests/shared/checks.js' +import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@peertube/peertube-models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' + +describe('Test users', function () { + let server: PeerTubeServer + let token: string + let userToken: string + let videoId: number + let userId: number + const user = { + username: 'user_1', + password: 'super password' + } + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, { + rates_limit: { + login: { + max: 30 + } + } + }) + + await setAccessTokensToServers([ server ]) + + await server.plugins.install({ npmName: 'peertube-theme-background-red' }) + }) + + describe('Creating a user', function () { + + it('Should be able to create a new user', async function () { + await server.users.create({ ...user, videoQuota: 2 * 1024 * 1024, adminFlags: UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST }) + }) + + it('Should be able to login with this user', async function () { + userToken = await server.login.getAccessToken(user) + }) + + it('Should be able to get user information', async function () { + const userMe = await server.users.getMyInfo({ token: userToken }) + + const userGet = await server.users.get({ userId: userMe.id, withStats: true }) + + for (const user of [ userMe, userGet ]) { + expect(user.username).to.equal('user_1') + expect(user.email).to.equal('user_1@example.com') + expect(user.nsfwPolicy).to.equal('display') + expect(user.videoQuota).to.equal(2 * 1024 * 1024) + expect(user.role.label).to.equal('User') + expect(user.id).to.be.a('number') + expect(user.account.displayName).to.equal('user_1') + expect(user.account.description).to.be.null + } + + expect(userMe.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST) + expect(userGet.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST) + + expect(userMe.specialPlaylists).to.have.lengthOf(1) + expect(userMe.specialPlaylists[0].type).to.equal(VideoPlaylistType.WATCH_LATER) + + // Check stats are included with withStats + expect(userGet.videosCount).to.be.a('number') + expect(userGet.videosCount).to.equal(0) + expect(userGet.videoCommentsCount).to.be.a('number') + expect(userGet.videoCommentsCount).to.equal(0) + expect(userGet.abusesCount).to.be.a('number') + expect(userGet.abusesCount).to.equal(0) + expect(userGet.abusesAcceptedCount).to.be.a('number') + expect(userGet.abusesAcceptedCount).to.equal(0) + }) + }) + + describe('Users listing', function () { + + it('Should list all the users', async function () { + const { data, total } = await server.users.list() + + expect(total).to.equal(2) + expect(data).to.be.an('array') + expect(data.length).to.equal(2) + + const user = data[0] + expect(user.username).to.equal('user_1') + expect(user.email).to.equal('user_1@example.com') + expect(user.nsfwPolicy).to.equal('display') + + const rootUser = data[1] + expect(rootUser.username).to.equal('root') + expect(rootUser.email).to.equal('admin' + server.internalServerNumber + '@example.com') + expect(user.nsfwPolicy).to.equal('display') + + expect(rootUser.lastLoginDate).to.exist + expect(user.lastLoginDate).to.exist + + userId = user.id + }) + + it('Should list only the first user by username asc', async function () { + const { total, data } = await server.users.list({ start: 0, count: 1, sort: 'username' }) + + expect(total).to.equal(2) + expect(data.length).to.equal(1) + + const user = data[0] + expect(user.username).to.equal('root') + expect(user.email).to.equal('admin' + server.internalServerNumber + '@example.com') + expect(user.role.label).to.equal('Administrator') + expect(user.nsfwPolicy).to.equal('display') + }) + + it('Should list only the first user by username desc', async function () { + const { total, data } = await server.users.list({ start: 0, count: 1, sort: '-username' }) + + expect(total).to.equal(2) + expect(data.length).to.equal(1) + + const user = data[0] + expect(user.username).to.equal('user_1') + expect(user.email).to.equal('user_1@example.com') + expect(user.nsfwPolicy).to.equal('display') + }) + + it('Should list only the second user by createdAt desc', async function () { + const { data, total } = await server.users.list({ start: 0, count: 1, sort: '-createdAt' }) + expect(total).to.equal(2) + + expect(data.length).to.equal(1) + + const user = data[0] + expect(user.username).to.equal('user_1') + expect(user.email).to.equal('user_1@example.com') + expect(user.nsfwPolicy).to.equal('display') + }) + + it('Should list all the users by createdAt asc', async function () { + const { data, total } = await server.users.list({ start: 0, count: 2, sort: 'createdAt' }) + + expect(total).to.equal(2) + expect(data.length).to.equal(2) + + expect(data[0].username).to.equal('root') + expect(data[0].email).to.equal('admin' + server.internalServerNumber + '@example.com') + expect(data[0].nsfwPolicy).to.equal('display') + + expect(data[1].username).to.equal('user_1') + expect(data[1].email).to.equal('user_1@example.com') + expect(data[1].nsfwPolicy).to.equal('display') + }) + + it('Should search user by username', async function () { + const { data, total } = await server.users.list({ start: 0, count: 2, sort: 'createdAt', search: 'oot' }) + expect(total).to.equal(1) + expect(data.length).to.equal(1) + expect(data[0].username).to.equal('root') + }) + + it('Should search user by email', async function () { + { + const { total, data } = await server.users.list({ start: 0, count: 2, sort: 'createdAt', search: 'r_1@exam' }) + expect(total).to.equal(1) + expect(data.length).to.equal(1) + expect(data[0].username).to.equal('user_1') + expect(data[0].email).to.equal('user_1@example.com') + } + + { + const { total, data } = await server.users.list({ start: 0, count: 2, sort: 'createdAt', search: 'example' }) + expect(total).to.equal(2) + expect(data.length).to.equal(2) + expect(data[0].username).to.equal('root') + expect(data[1].username).to.equal('user_1') + } + }) + }) + + describe('Update my account', function () { + + it('Should update my password', async function () { + await server.users.updateMe({ + token: userToken, + currentPassword: 'super password', + password: 'new password' + }) + user.password = 'new password' + + await server.login.login({ user }) + }) + + it('Should be able to change the NSFW display attribute', async function () { + await server.users.updateMe({ + token: userToken, + nsfwPolicy: 'do_not_list' + }) + + const user = await server.users.getMyInfo({ token: userToken }) + expect(user.username).to.equal('user_1') + expect(user.email).to.equal('user_1@example.com') + expect(user.nsfwPolicy).to.equal('do_not_list') + expect(user.videoQuota).to.equal(2 * 1024 * 1024) + expect(user.id).to.be.a('number') + expect(user.account.displayName).to.equal('user_1') + expect(user.account.description).to.be.null + }) + + it('Should be able to change the autoPlayVideo attribute', async function () { + await server.users.updateMe({ + token: userToken, + autoPlayVideo: false + }) + + const user = await server.users.getMyInfo({ token: userToken }) + expect(user.autoPlayVideo).to.be.false + }) + + it('Should be able to change the autoPlayNextVideo attribute', async function () { + await server.users.updateMe({ + token: userToken, + autoPlayNextVideo: true + }) + + const user = await server.users.getMyInfo({ token: userToken }) + expect(user.autoPlayNextVideo).to.be.true + }) + + it('Should be able to change the p2p attribute', async function () { + await server.users.updateMe({ + token: userToken, + p2pEnabled: true + }) + + const user = await server.users.getMyInfo({ token: userToken }) + expect(user.p2pEnabled).to.be.true + }) + + it('Should be able to change the email attribute', async function () { + await server.users.updateMe({ + token: userToken, + currentPassword: 'new password', + email: 'updated@example.com' + }) + + const user = await server.users.getMyInfo({ token: userToken }) + expect(user.username).to.equal('user_1') + expect(user.email).to.equal('updated@example.com') + expect(user.nsfwPolicy).to.equal('do_not_list') + expect(user.videoQuota).to.equal(2 * 1024 * 1024) + expect(user.id).to.be.a('number') + expect(user.account.displayName).to.equal('user_1') + expect(user.account.description).to.be.null + }) + + it('Should be able to update my avatar with a gif', async function () { + const fixture = 'avatar.gif' + + await server.users.updateMyAvatar({ token: userToken, fixture }) + + const user = await server.users.getMyInfo({ token: userToken }) + for (const avatar of user.account.avatars) { + await testImageSize(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.gif') + } + }) + + it('Should be able to update my avatar with a gif, and then a png', async function () { + for (const extension of [ '.png', '.gif' ]) { + const fixture = 'avatar' + extension + + await server.users.updateMyAvatar({ token: userToken, fixture }) + + const user = await server.users.getMyInfo({ token: userToken }) + for (const avatar of user.account.avatars) { + await testImageSize(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, extension) + } + } + }) + + it('Should be able to update my display name', async function () { + await server.users.updateMe({ token: userToken, displayName: 'new display name' }) + + const user = await server.users.getMyInfo({ token: userToken }) + expect(user.username).to.equal('user_1') + expect(user.email).to.equal('updated@example.com') + expect(user.nsfwPolicy).to.equal('do_not_list') + expect(user.videoQuota).to.equal(2 * 1024 * 1024) + expect(user.id).to.be.a('number') + expect(user.account.displayName).to.equal('new display name') + expect(user.account.description).to.be.null + }) + + it('Should be able to update my description', async function () { + await server.users.updateMe({ token: userToken, description: 'my super description updated' }) + + const user = await server.users.getMyInfo({ token: userToken }) + expect(user.username).to.equal('user_1') + expect(user.email).to.equal('updated@example.com') + expect(user.nsfwPolicy).to.equal('do_not_list') + expect(user.videoQuota).to.equal(2 * 1024 * 1024) + expect(user.id).to.be.a('number') + expect(user.account.displayName).to.equal('new display name') + expect(user.account.description).to.equal('my super description updated') + expect(user.noWelcomeModal).to.be.false + expect(user.noInstanceConfigWarningModal).to.be.false + expect(user.noAccountSetupWarningModal).to.be.false + }) + + it('Should be able to update my theme', async function () { + for (const theme of [ 'background-red', 'default', 'instance-default' ]) { + await server.users.updateMe({ token: userToken, theme }) + + const user = await server.users.getMyInfo({ token: userToken }) + expect(user.theme).to.equal(theme) + } + }) + + it('Should be able to update my modal preferences', async function () { + await server.users.updateMe({ + token: userToken, + noInstanceConfigWarningModal: true, + noWelcomeModal: true, + noAccountSetupWarningModal: true + }) + + const user = await server.users.getMyInfo({ token: userToken }) + expect(user.noWelcomeModal).to.be.true + expect(user.noInstanceConfigWarningModal).to.be.true + expect(user.noAccountSetupWarningModal).to.be.true + }) + }) + + describe('Updating another user', function () { + + it('Should be able to update another user', async function () { + await server.users.update({ + userId, + token, + email: 'updated2@example.com', + emailVerified: true, + videoQuota: 42, + role: UserRole.MODERATOR, + adminFlags: UserAdminFlag.NONE, + pluginAuth: 'toto' + }) + + const user = await server.users.get({ token, userId }) + + expect(user.username).to.equal('user_1') + expect(user.email).to.equal('updated2@example.com') + expect(user.emailVerified).to.be.true + expect(user.nsfwPolicy).to.equal('do_not_list') + expect(user.videoQuota).to.equal(42) + expect(user.role.label).to.equal('Moderator') + expect(user.id).to.be.a('number') + expect(user.adminFlags).to.equal(UserAdminFlag.NONE) + expect(user.pluginAuth).to.equal('toto') + }) + + it('Should reset the auth plugin', async function () { + await server.users.update({ userId, token, pluginAuth: null }) + + const user = await server.users.get({ token, userId }) + expect(user.pluginAuth).to.be.null + }) + + it('Should have removed the user token', async function () { + await server.users.getMyQuotaUsed({ token: userToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + + userToken = await server.login.getAccessToken(user) + }) + + it('Should be able to update another user password', async function () { + await server.users.update({ userId, token, password: 'password updated' }) + + await server.users.getMyQuotaUsed({ token: userToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + + await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + user.password = 'password updated' + userToken = await server.login.getAccessToken(user) + }) + }) + + describe('Remove a user', function () { + + before(async function () { + await server.users.update({ + userId, + token, + videoQuota: 2 * 1024 * 1024 + }) + + await server.videos.quickUpload({ name: 'user video', token: userToken, fixture: 'video_short.webm' }) + await server.videos.quickUpload({ name: 'root video' }) + + const { total } = await server.videos.list() + expect(total).to.equal(2) + }) + + it('Should be able to remove this user', async function () { + await server.users.remove({ userId, token }) + }) + + it('Should not be able to login with this user', async function () { + await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not have videos of this user', async function () { + const { data, total } = await server.videos.list() + expect(total).to.equal(1) + + const video = data[0] + expect(video.account.name).to.equal('root') + }) + }) + + describe('User blocking', function () { + let user16Id: number + let user16AccessToken: string + + const user16 = { + username: 'user_16', + password: 'my super password' + } + + it('Should block a user', async function () { + const user = await server.users.create({ ...user16 }) + user16Id = user.id + + user16AccessToken = await server.login.getAccessToken(user16) + + await server.users.getMyInfo({ token: user16AccessToken, expectedStatus: HttpStatusCode.OK_200 }) + await server.users.banUser({ userId: user16Id }) + + await server.users.getMyInfo({ token: user16AccessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await server.login.login({ user: user16, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should search user by banned status', async function () { + { + const { data, total } = await server.users.list({ start: 0, count: 2, sort: 'createdAt', blocked: true }) + expect(total).to.equal(1) + expect(data.length).to.equal(1) + + expect(data[0].username).to.equal(user16.username) + } + + { + const { data, total } = await server.users.list({ start: 0, count: 2, sort: 'createdAt', blocked: false }) + expect(total).to.equal(1) + expect(data.length).to.equal(1) + + expect(data[0].username).to.not.equal(user16.username) + } + }) + + it('Should unblock a user', async function () { + await server.users.unbanUser({ userId: user16Id }) + user16AccessToken = await server.login.getAccessToken(user16) + await server.users.getMyInfo({ token: user16AccessToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('User stats', function () { + let user17Id: number + let user17AccessToken: string + + it('Should report correct initial statistics about a user', async function () { + const user17 = { + username: 'user_17', + password: 'my super password' + } + const created = await server.users.create({ ...user17 }) + + user17Id = created.id + user17AccessToken = await server.login.getAccessToken(user17) + + const user = await server.users.get({ userId: user17Id, withStats: true }) + expect(user.videosCount).to.equal(0) + expect(user.videoCommentsCount).to.equal(0) + expect(user.abusesCount).to.equal(0) + expect(user.abusesCreatedCount).to.equal(0) + expect(user.abusesAcceptedCount).to.equal(0) + }) + + it('Should report correct videos count', async function () { + const attributes = { name: 'video to test user stats' } + await server.videos.upload({ token: user17AccessToken, attributes }) + + const { data } = await server.videos.list() + videoId = data.find(video => video.name === attributes.name).id + + const user = await server.users.get({ userId: user17Id, withStats: true }) + expect(user.videosCount).to.equal(1) + }) + + it('Should report correct video comments for user', async function () { + const text = 'super comment' + await server.comments.createThread({ token: user17AccessToken, videoId, text }) + + const user = await server.users.get({ userId: user17Id, withStats: true }) + expect(user.videoCommentsCount).to.equal(1) + }) + + it('Should report correct abuses counts', async function () { + const reason = 'my super bad reason' + await server.abuses.report({ token: user17AccessToken, videoId, reason }) + + const body1 = await server.abuses.getAdminList() + const abuseId = body1.data[0].id + + const user2 = await server.users.get({ userId: user17Id, withStats: true }) + expect(user2.abusesCount).to.equal(1) // number of incriminations + expect(user2.abusesCreatedCount).to.equal(1) // number of reports created + + await server.abuses.update({ abuseId, body: { state: AbuseState.ACCEPTED } }) + + const user3 = await server.users.get({ userId: user17Id, withStats: true }) + expect(user3.abusesAcceptedCount).to.equal(1) // number of reports created accepted + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/channel-import-videos.ts b/packages/tests/src/api/videos/channel-import-videos.ts new file mode 100644 index 000000000..d0e47fe95 --- /dev/null +++ b/packages/tests/src/api/videos/channel-import-videos.ts @@ -0,0 +1,161 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { + createSingleServer, + getServerImportConfig, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test videos import in a channel', function () { + if (areHttpImportTestsDisabled()) return + + function runSuite (mode: 'youtube-dl' | 'yt-dlp') { + + describe('Import using ' + mode, function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(120_000) + + server = await createSingleServer(1, getServerImportConfig(mode)) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableChannelSync() + }) + + it('Should import a whole channel without specifying the sync id', async function () { + this.timeout(240_000) + + await server.channels.importVideos({ channelName: server.store.channel.name, externalChannelUrl: FIXTURE_URLS.youtubeChannel }) + await waitJobs(server) + + const videos = await server.videos.listByChannel({ handle: server.store.channel.name }) + expect(videos.total).to.equal(2) + }) + + it('These imports should not have a sync id', async function () { + const { total, data } = await server.imports.getMyVideoImports() + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + for (const videoImport of data) { + expect(videoImport.videoChannelSync).to.not.exist + } + }) + + it('Should import a whole channel and specifying the sync id', async function () { + this.timeout(240_000) + + { + server.store.channel.name = 'channel2' + const { id } = await server.channels.create({ attributes: { name: server.store.channel.name } }) + server.store.channel.id = id + } + + { + const attributes = { + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelId: server.store.channel.id + } + + const { videoChannelSync } = await server.channelSyncs.create({ attributes }) + server.store.videoChannelSync = videoChannelSync + + await waitJobs(server) + } + + await server.channels.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelSyncId: server.store.videoChannelSync.id + }) + + await waitJobs(server) + }) + + it('These imports should have a sync id', async function () { + const { total, data } = await server.imports.getMyVideoImports() + + expect(total).to.equal(4) + expect(data).to.have.lengthOf(4) + + const importsWithSyncId = data.filter(i => !!i.videoChannelSync) + expect(importsWithSyncId).to.have.lengthOf(2) + + for (const videoImport of importsWithSyncId) { + expect(videoImport.videoChannelSync).to.exist + expect(videoImport.videoChannelSync.id).to.equal(server.store.videoChannelSync.id) + } + }) + + it('Should be able to filter imports by this sync id', async function () { + const { total, data } = await server.imports.getMyVideoImports({ videoChannelSyncId: server.store.videoChannelSync.id }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + for (const videoImport of data) { + expect(videoImport.videoChannelSync).to.exist + expect(videoImport.videoChannelSync.id).to.equal(server.store.videoChannelSync.id) + } + }) + + it('Should limit max amount of videos synced on full sync', async function () { + this.timeout(240_000) + + await server.kill() + await server.run({ + import: { + video_channel_synchronization: { + full_sync_videos_limit: 1 + } + } + }) + + const { id } = await server.channels.create({ attributes: { name: 'channel3' } }) + const channel3Id = id + + const { videoChannelSync } = await server.channelSyncs.create({ + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelId: channel3Id + } + }) + const syncId = videoChannelSync.id + + await waitJobs(server) + + await server.channels.importVideos({ + channelName: 'channel3', + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelSyncId: syncId + }) + + await waitJobs(server) + + const { total, data } = await server.videos.listByChannel({ handle: 'channel3' }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + after(async function () { + await server?.kill() + }) + }) + } + + runSuite('yt-dlp') + + // FIXME: With recent changes on youtube, youtube-dl doesn't fetch live replays which means the test suite fails + // runSuite('youtube-dl') +}) diff --git a/packages/tests/src/api/videos/index.ts b/packages/tests/src/api/videos/index.ts new file mode 100644 index 000000000..fcb1d5a81 --- /dev/null +++ b/packages/tests/src/api/videos/index.ts @@ -0,0 +1,23 @@ +import './multiple-servers.js' +import './resumable-upload.js' +import './single-server.js' +import './video-captions.js' +import './video-change-ownership.js' +import './video-channels.js' +import './channel-import-videos.js' +import './video-channel-syncs.js' +import './video-comments.js' +import './video-description.js' +import './video-files.js' +import './video-imports.js' +import './video-nsfw.js' +import './video-playlists.js' +import './video-playlist-thumbnails.js' +import './video-source.js' +import './video-privacy.js' +import './video-schedule-update.js' +import './videos-common-filters.js' +import './videos-history.js' +import './videos-overview.js' +import './video-static-file-privacy.js' +import './video-storyboard.js' diff --git a/packages/tests/src/api/videos/multiple-servers.ts b/packages/tests/src/api/videos/multiple-servers.ts new file mode 100644 index 000000000..03afd7cbb --- /dev/null +++ b/packages/tests/src/api/videos/multiple-servers.ts @@ -0,0 +1,1095 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import request from 'supertest' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoCommentThreadTree, VideoPrivacy } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + waitJobs +} from '@peertube/peertube-server-commands' +import { testImageGeneratedByFFmpeg, dateIsValid } from '@tests/shared/checks.js' +import { checkTmpIsEmpty } from '@tests/shared/directories.js' +import { completeVideoCheck, saveVideoInServers, checkVideoFilesWereRemoved } from '@tests/shared/videos.js' +import { checkWebTorrentWorks } from '@tests/shared/webtorrent.js' + +describe('Test multiple servers', function () { + let servers: PeerTubeServer[] = [] + const toRemove = [] + let videoUUID = '' + let videoChannelId: number + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + + { + const videoChannel = { + name: 'super_channel_name', + displayName: 'my channel', + description: 'super channel' + } + await servers[0].channels.create({ attributes: videoChannel }) + await setDefaultChannelAvatar(servers[0], videoChannel.name) + await setDefaultAccountAvatar(servers) + + const { data } = await servers[0].channels.list({ start: 0, count: 1 }) + videoChannelId = data[0].id + } + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + // Server 1 and server 3 follow each other + await doubleFollow(servers[0], servers[2]) + // Server 2 and server 3 follow each other + await doubleFollow(servers[1], servers[2]) + }) + + it('Should not have videos for all servers', async function () { + for (const server of servers) { + const { data } = await server.videos.list() + expect(data).to.be.an('array') + expect(data.length).to.equal(0) + } + }) + + describe('Should upload the video and propagate on each server', function () { + + it('Should upload the video on server 1 and propagate on each server', async function () { + this.timeout(60000) + + const attributes = { + name: 'my super name for server 1', + category: 5, + licence: 4, + language: 'ja', + nsfw: true, + description: 'my super description for server 1', + support: 'my super support text for server 1', + originallyPublishedAt: '2019-02-10T13:38:14.449Z', + tags: [ 'tag1p1', 'tag2p1' ], + channelId: videoChannelId, + fixture: 'video_short1.webm' + } + await servers[0].videos.upload({ attributes }) + + await waitJobs(servers) + + // All servers should have this video + let publishedAt: string = null + for (const server of servers) { + const isLocal = server.port === servers[0].port + const checkAttributes = { + name: 'my super name for server 1', + category: 5, + licence: 4, + language: 'ja', + nsfw: true, + description: 'my super description for server 1', + support: 'my super support text for server 1', + originallyPublishedAt: '2019-02-10T13:38:14.449Z', + account: { + name: 'root', + host: servers[0].host + }, + isLocal, + publishedAt, + duration: 10, + tags: [ 'tag1p1', 'tag2p1' ], + privacy: VideoPrivacy.PUBLIC, + commentsEnabled: true, + downloadEnabled: true, + channel: { + displayName: 'my channel', + name: 'super_channel_name', + description: 'super channel', + isLocal + }, + fixture: 'video_short1.webm', + files: [ + { + resolution: 720, + size: 572456 + } + ] + } + + const { data } = await server.videos.list() + expect(data).to.be.an('array') + expect(data.length).to.equal(1) + const video = data[0] + + await completeVideoCheck({ server, originServer: servers[0], videoUUID: video.uuid, attributes: checkAttributes }) + publishedAt = video.publishedAt as string + + expect(video.channel.avatars).to.have.lengthOf(2) + expect(video.account.avatars).to.have.lengthOf(2) + + for (const image of [ ...video.channel.avatars, ...video.account.avatars ]) { + expect(image.createdAt).to.exist + expect(image.updatedAt).to.exist + expect(image.width).to.be.above(20).and.below(1000) + expect(image.path).to.exist + + await makeGetRequest({ + url: server.url, + path: image.path, + expectedStatus: HttpStatusCode.OK_200 + }) + } + } + }) + + it('Should upload the video on server 2 and propagate on each server', async function () { + this.timeout(240000) + + const user = { + username: 'user1', + password: 'super_password' + } + await servers[1].users.create({ username: user.username, password: user.password }) + const userAccessToken = await servers[1].login.getAccessToken(user) + + const attributes = { + name: 'my super name for server 2', + category: 4, + licence: 3, + language: 'de', + nsfw: true, + description: 'my super description for server 2', + support: 'my super support text for server 2', + tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ], + fixture: 'video_short2.webm', + thumbnailfile: 'custom-thumbnail.jpg', + previewfile: 'custom-preview.jpg' + } + await servers[1].videos.upload({ token: userAccessToken, attributes, mode: 'resumable' }) + + // Transcoding + await waitJobs(servers) + + // All servers should have this video + for (const server of servers) { + const isLocal = server.url === servers[1].url + const checkAttributes = { + name: 'my super name for server 2', + category: 4, + licence: 3, + language: 'de', + nsfw: true, + description: 'my super description for server 2', + support: 'my super support text for server 2', + account: { + name: 'user1', + host: servers[1].host + }, + isLocal, + commentsEnabled: true, + downloadEnabled: true, + duration: 5, + tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main user1 channel', + name: 'user1_channel', + description: 'super channel', + isLocal + }, + fixture: 'video_short2.webm', + files: [ + { + resolution: 240, + size: 270000 + }, + { + resolution: 360, + size: 359000 + }, + { + resolution: 480, + size: 465000 + }, + { + resolution: 720, + size: 750000 + } + ], + thumbnailfile: 'custom-thumbnail', + previewfile: 'custom-preview' + } + + const { data } = await server.videos.list() + expect(data).to.be.an('array') + expect(data.length).to.equal(2) + const video = data[1] + + await completeVideoCheck({ server, originServer: servers[1], videoUUID: video.uuid, attributes: checkAttributes }) + } + }) + + it('Should upload two videos on server 3 and propagate on each server', async function () { + this.timeout(45000) + + { + const attributes = { + name: 'my super name for server 3', + category: 6, + licence: 5, + language: 'de', + nsfw: true, + description: 'my super description for server 3', + support: 'my super support text for server 3', + tags: [ 'tag1p3' ], + fixture: 'video_short3.webm' + } + await servers[2].videos.upload({ attributes }) + } + + { + const attributes = { + name: 'my super name for server 3-2', + category: 7, + licence: 6, + language: 'ko', + nsfw: false, + description: 'my super description for server 3-2', + support: 'my super support text for server 3-2', + tags: [ 'tag2p3', 'tag3p3', 'tag4p3' ], + fixture: 'video_short.webm' + } + await servers[2].videos.upload({ attributes }) + } + + await waitJobs(servers) + + // All servers should have this video + for (const server of servers) { + const isLocal = server.url === servers[2].url + const { data } = await server.videos.list() + + expect(data).to.be.an('array') + expect(data.length).to.equal(4) + + // We not sure about the order of the two last uploads + let video1 = null + let video2 = null + if (data[2].name === 'my super name for server 3') { + video1 = data[2] + video2 = data[3] + } else { + video1 = data[3] + video2 = data[2] + } + + const checkAttributesVideo1 = { + name: 'my super name for server 3', + category: 6, + licence: 5, + language: 'de', + nsfw: true, + description: 'my super description for server 3', + support: 'my super support text for server 3', + account: { + name: 'root', + host: servers[2].host + }, + isLocal, + duration: 5, + commentsEnabled: true, + downloadEnabled: true, + tags: [ 'tag1p3' ], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal + }, + fixture: 'video_short3.webm', + files: [ + { + resolution: 720, + size: 292677 + } + ] + } + await completeVideoCheck({ server, originServer: servers[2], videoUUID: video1.uuid, attributes: checkAttributesVideo1 }) + + const checkAttributesVideo2 = { + name: 'my super name for server 3-2', + category: 7, + licence: 6, + language: 'ko', + nsfw: false, + description: 'my super description for server 3-2', + support: 'my super support text for server 3-2', + account: { + name: 'root', + host: servers[2].host + }, + commentsEnabled: true, + downloadEnabled: true, + isLocal, + duration: 5, + tags: [ 'tag2p3', 'tag3p3', 'tag4p3' ], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal + }, + fixture: 'video_short.webm', + files: [ + { + resolution: 720, + size: 218910 + } + ] + } + await completeVideoCheck({ server, originServer: servers[2], videoUUID: video2.uuid, attributes: checkAttributesVideo2 }) + } + }) + }) + + describe('It should list local videos', function () { + it('Should list only local videos on server 1', async function () { + const { data, total } = await servers[0].videos.list({ isLocal: true }) + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data.length).to.equal(1) + expect(data[0].name).to.equal('my super name for server 1') + }) + + it('Should list only local videos on server 2', async function () { + const { data, total } = await servers[1].videos.list({ isLocal: true }) + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data.length).to.equal(1) + expect(data[0].name).to.equal('my super name for server 2') + }) + + it('Should list only local videos on server 3', async function () { + const { data, total } = await servers[2].videos.list({ isLocal: true }) + + expect(total).to.equal(2) + expect(data).to.be.an('array') + expect(data.length).to.equal(2) + expect(data[0].name).to.equal('my super name for server 3') + expect(data[1].name).to.equal('my super name for server 3-2') + }) + }) + + describe('Should seed the uploaded video', function () { + + it('Should add the file 1 by asking server 3', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[2].videos.list() + + const video = data[0] + toRemove.push(data[2]) + toRemove.push(data[3]) + + const videoDetails = await servers[2].videos.get({ id: video.id }) + + await checkWebTorrentWorks(videoDetails.files[0].magnetUri) + }) + + it('Should add the file 2 by asking server 1', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[0].videos.list() + + const video = data[1] + const videoDetails = await servers[0].videos.get({ id: video.id }) + + await checkWebTorrentWorks(videoDetails.files[0].magnetUri) + }) + + it('Should add the file 3 by asking server 2', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[1].videos.list() + + const video = data[2] + const videoDetails = await servers[1].videos.get({ id: video.id }) + + await checkWebTorrentWorks(videoDetails.files[0].magnetUri) + }) + + it('Should add the file 3-2 by asking server 1', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[0].videos.list() + + const video = data[3] + const videoDetails = await servers[0].videos.get({ id: video.id }) + + await checkWebTorrentWorks(videoDetails.files[0].magnetUri) + }) + + it('Should add the file 2 in 360p by asking server 1', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[0].videos.list() + + const video = data.find(v => v.name === 'my super name for server 2') + const videoDetails = await servers[0].videos.get({ id: video.id }) + + const file = videoDetails.files.find(f => f.resolution.id === 360) + expect(file).not.to.be.undefined + + await checkWebTorrentWorks(file.magnetUri) + }) + }) + + describe('Should update video views, likes and dislikes', function () { + let localVideosServer3 = [] + let remoteVideosServer1 = [] + let remoteVideosServer2 = [] + let remoteVideosServer3 = [] + + before(async function () { + { + const { data } = await servers[0].videos.list() + remoteVideosServer1 = data.filter(video => video.isLocal === false).map(video => video.uuid) + } + + { + const { data } = await servers[1].videos.list() + remoteVideosServer2 = data.filter(video => video.isLocal === false).map(video => video.uuid) + } + + { + const { data } = await servers[2].videos.list() + localVideosServer3 = data.filter(video => video.isLocal === true).map(video => video.uuid) + remoteVideosServer3 = data.filter(video => video.isLocal === false).map(video => video.uuid) + } + }) + + it('Should view multiple videos on owned servers', async function () { + this.timeout(30000) + + await servers[2].views.simulateView({ id: localVideosServer3[0] }) + await wait(1000) + + await servers[2].views.simulateView({ id: localVideosServer3[0] }) + await servers[2].views.simulateView({ id: localVideosServer3[1] }) + + await wait(1000) + + await servers[2].views.simulateView({ id: localVideosServer3[0] }) + await servers[2].views.simulateView({ id: localVideosServer3[0] }) + + await waitJobs(servers) + + for (const server of servers) { + await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } }) + } + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video0 = data.find(v => v.uuid === localVideosServer3[0]) + const video1 = data.find(v => v.uuid === localVideosServer3[1]) + + expect(video0.views).to.equal(3) + expect(video1.views).to.equal(1) + } + }) + + it('Should view multiple videos on each servers', async function () { + this.timeout(45000) + + const tasks: Promise[] = [] + tasks.push(servers[0].views.simulateView({ id: remoteVideosServer1[0] })) + tasks.push(servers[1].views.simulateView({ id: remoteVideosServer2[0] })) + tasks.push(servers[1].views.simulateView({ id: remoteVideosServer2[0] })) + tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[0] })) + tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: localVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: localVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: localVideosServer3[1] })) + + await Promise.all(tasks) + + await waitJobs(servers) + + for (const server of servers) { + await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } }) + } + + await waitJobs(servers) + + let baseVideos = null + + for (const server of servers) { + const { data } = await server.videos.list() + + // Initialize base videos for future comparisons + if (baseVideos === null) { + baseVideos = data + continue + } + + for (const baseVideo of baseVideos) { + const sameVideo = data.find(video => video.name === baseVideo.name) + expect(baseVideo.views).to.equal(sameVideo.views) + } + } + }) + + it('Should like and dislikes videos on different services', async function () { + this.timeout(50000) + + await servers[0].videos.rate({ id: remoteVideosServer1[0], rating: 'like' }) + await wait(500) + await servers[0].videos.rate({ id: remoteVideosServer1[0], rating: 'dislike' }) + await wait(500) + await servers[0].videos.rate({ id: remoteVideosServer1[0], rating: 'like' }) + await servers[2].videos.rate({ id: localVideosServer3[1], rating: 'like' }) + await wait(500) + await servers[2].videos.rate({ id: localVideosServer3[1], rating: 'dislike' }) + await servers[2].videos.rate({ id: remoteVideosServer3[1], rating: 'dislike' }) + await wait(500) + await servers[2].videos.rate({ id: remoteVideosServer3[0], rating: 'like' }) + + await waitJobs(servers) + await wait(5000) + await waitJobs(servers) + + let baseVideos = null + for (const server of servers) { + const { data } = await server.videos.list() + + // Initialize base videos for future comparisons + if (baseVideos === null) { + baseVideos = data + continue + } + + for (const baseVideo of baseVideos) { + const sameVideo = data.find(video => video.name === baseVideo.name) + expect(baseVideo.likes).to.equal(sameVideo.likes, `Likes of ${sameVideo.uuid} do not correspond`) + expect(baseVideo.dislikes).to.equal(sameVideo.dislikes, `Dislikes of ${sameVideo.uuid} do not correspond`) + } + } + }) + }) + + describe('Should manipulate these videos', function () { + let updatedAtMin: Date + + it('Should update video 3', async function () { + this.timeout(30000) + + const attributes = { + name: 'my super video updated', + category: 10, + licence: 7, + language: 'fr', + nsfw: true, + description: 'my super description updated', + support: 'my super support text updated', + tags: [ 'tag_up_1', 'tag_up_2' ], + thumbnailfile: 'custom-thumbnail.jpg', + originallyPublishedAt: '2019-02-11T13:38:14.449Z', + previewfile: 'custom-preview.jpg' + } + + updatedAtMin = new Date() + await servers[2].videos.update({ id: toRemove[0].id, attributes }) + + await waitJobs(servers) + }) + + it('Should have the video 3 updated on each server', async function () { + this.timeout(30000) + + for (const server of servers) { + const { data } = await server.videos.list() + + const videoUpdated = data.find(video => video.name === 'my super video updated') + expect(!!videoUpdated).to.be.true + + expect(new Date(videoUpdated.updatedAt)).to.be.greaterThan(updatedAtMin) + + const isLocal = server.url === servers[2].url + const checkAttributes = { + name: 'my super video updated', + category: 10, + licence: 7, + language: 'fr', + nsfw: true, + description: 'my super description updated', + support: 'my super support text updated', + originallyPublishedAt: '2019-02-11T13:38:14.449Z', + account: { + name: 'root', + host: servers[2].host + }, + isLocal, + duration: 5, + commentsEnabled: true, + downloadEnabled: true, + tags: [ 'tag_up_1', 'tag_up_2' ], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal + }, + fixture: 'video_short3.webm', + files: [ + { + resolution: 720, + size: 292677 + } + ], + thumbnailfile: 'custom-thumbnail', + previewfile: 'custom-preview' + } + await completeVideoCheck({ server, originServer: servers[2], videoUUID: videoUpdated.uuid, attributes: checkAttributes }) + } + }) + + it('Should only update thumbnail and update updatedAt attribute', async function () { + this.timeout(30000) + + const attributes = { + thumbnailfile: 'custom-thumbnail.jpg' + } + + updatedAtMin = new Date() + await servers[2].videos.update({ id: toRemove[0].id, attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const videoUpdated = data.find(video => video.name === 'my super video updated') + expect(new Date(videoUpdated.updatedAt)).to.be.greaterThan(updatedAtMin) + } + }) + + it('Should remove the videos 3 and 3-2 by asking server 3 and correctly delete files', async function () { + this.timeout(30000) + + for (const id of [ toRemove[0].id, toRemove[1].id ]) { + await saveVideoInServers(servers, id) + + await servers[2].videos.remove({ id }) + + await waitJobs(servers) + + for (const server of servers) { + await checkVideoFilesWereRemoved({ server, video: server.store.videoDetails }) + } + } + }) + + it('Should have videos 1 and 3 on each server', async function () { + for (const server of servers) { + const { data } = await server.videos.list() + + expect(data).to.be.an('array') + expect(data.length).to.equal(2) + expect(data[0].name).not.to.equal(data[1].name) + expect(data[0].name).not.to.equal(toRemove[0].name) + expect(data[1].name).not.to.equal(toRemove[0].name) + expect(data[0].name).not.to.equal(toRemove[1].name) + expect(data[1].name).not.to.equal(toRemove[1].name) + + videoUUID = data.find(video => video.name === 'my super name for server 1').uuid + } + }) + + it('Should get the same video by UUID on each server', async function () { + let baseVideo = null + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + if (baseVideo === null) { + baseVideo = video + continue + } + + expect(baseVideo.name).to.equal(video.name) + expect(baseVideo.uuid).to.equal(video.uuid) + expect(baseVideo.category.id).to.equal(video.category.id) + expect(baseVideo.language.id).to.equal(video.language.id) + expect(baseVideo.licence.id).to.equal(video.licence.id) + expect(baseVideo.nsfw).to.equal(video.nsfw) + expect(baseVideo.account.name).to.equal(video.account.name) + expect(baseVideo.account.displayName).to.equal(video.account.displayName) + expect(baseVideo.account.url).to.equal(video.account.url) + expect(baseVideo.account.host).to.equal(video.account.host) + expect(baseVideo.tags).to.deep.equal(video.tags) + } + }) + + it('Should get the preview from each server', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + await testImageGeneratedByFFmpeg(server.url, 'video_short1-preview.webm', video.previewPath) + } + }) + }) + + describe('Should comment these videos', function () { + let childOfFirstChild: VideoCommentThreadTree + + it('Should add comment (threads and replies)', async function () { + this.timeout(25000) + + { + const text = 'my super first comment' + await servers[0].comments.createThread({ videoId: videoUUID, text }) + } + + { + const text = 'my super second comment' + await servers[2].comments.createThread({ videoId: videoUUID, text }) + } + + await waitJobs(servers) + + { + const threadId = await servers[1].comments.findCommentId({ videoId: videoUUID, text: 'my super first comment' }) + + const text = 'my super answer to thread 1' + await servers[1].comments.addReply({ videoId: videoUUID, toCommentId: threadId, text }) + } + + await waitJobs(servers) + + { + const threadId = await servers[2].comments.findCommentId({ videoId: videoUUID, text: 'my super first comment' }) + + const body = await servers[2].comments.getThread({ videoId: videoUUID, threadId }) + const childCommentId = body.children[0].comment.id + + const text3 = 'my second answer to thread 1' + await servers[2].comments.addReply({ videoId: videoUUID, toCommentId: threadId, text: text3 }) + + const text2 = 'my super answer to answer of thread 1' + await servers[2].comments.addReply({ videoId: videoUUID, toCommentId: childCommentId, text: text2 }) + } + + await waitJobs(servers) + }) + + it('Should have these threads', async function () { + for (const server of servers) { + const body = await server.comments.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + + { + const comment = body.data.find(c => c.text === 'my super first comment') + expect(comment).to.not.be.undefined + expect(comment.inReplyToCommentId).to.be.null + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(servers[0].host) + expect(comment.totalReplies).to.equal(3) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + } + + { + const comment = body.data.find(c => c.text === 'my super second comment') + expect(comment).to.not.be.undefined + expect(comment.inReplyToCommentId).to.be.null + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(servers[2].host) + expect(comment.totalReplies).to.equal(0) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + } + } + }) + + it('Should have these comments', async function () { + for (const server of servers) { + const body = await server.comments.listThreads({ videoId: videoUUID }) + const threadId = body.data.find(c => c.text === 'my super first comment').id + + const tree = await server.comments.getThread({ videoId: videoUUID, threadId }) + + expect(tree.comment.text).equal('my super first comment') + expect(tree.comment.account.name).equal('root') + expect(tree.comment.account.host).equal(servers[0].host) + expect(tree.children).to.have.lengthOf(2) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.comment.account.name).equal('root') + expect(firstChild.comment.account.host).equal(servers[1].host) + expect(firstChild.children).to.have.lengthOf(1) + + childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') + expect(childOfFirstChild.comment.account.name).equal('root') + expect(childOfFirstChild.comment.account.host).equal(servers[2].host) + expect(childOfFirstChild.children).to.have.lengthOf(0) + + const secondChild = tree.children[1] + expect(secondChild.comment.text).to.equal('my second answer to thread 1') + expect(secondChild.comment.account.name).equal('root') + expect(secondChild.comment.account.host).equal(servers[2].host) + expect(secondChild.children).to.have.lengthOf(0) + } + }) + + it('Should delete a reply', async function () { + this.timeout(30000) + + await servers[2].comments.delete({ videoId: videoUUID, commentId: childOfFirstChild.comment.id }) + + await waitJobs(servers) + }) + + it('Should have this comment marked as deleted', async function () { + for (const server of servers) { + const { data } = await server.comments.listThreads({ videoId: videoUUID }) + const threadId = data.find(c => c.text === 'my super first comment').id + + const tree = await server.comments.getThread({ videoId: videoUUID, threadId }) + expect(tree.comment.text).equal('my super first comment') + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.children).to.have.lengthOf(1) + + const deletedComment = firstChild.children[0].comment + expect(deletedComment.isDeleted).to.be.true + expect(deletedComment.deletedAt).to.not.be.null + expect(deletedComment.account).to.be.null + expect(deletedComment.text).to.equal('') + + const secondChild = tree.children[1] + expect(secondChild.comment.text).to.equal('my second answer to thread 1') + } + }) + + it('Should delete the thread comments', async function () { + this.timeout(30000) + + const { data } = await servers[0].comments.listThreads({ videoId: videoUUID }) + const commentId = data.find(c => c.text === 'my super first comment').id + await servers[0].comments.delete({ videoId: videoUUID, commentId }) + + await waitJobs(servers) + }) + + it('Should have the threads marked as deleted on other servers too', async function () { + for (const server of servers) { + const body = await server.comments.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + + { + const comment = body.data[0] + expect(comment).to.not.be.undefined + expect(comment.inReplyToCommentId).to.be.null + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(servers[2].host) + expect(comment.totalReplies).to.equal(0) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + } + + { + const deletedComment = body.data[1] + expect(deletedComment).to.not.be.undefined + expect(deletedComment.isDeleted).to.be.true + expect(deletedComment.deletedAt).to.not.be.null + expect(deletedComment.text).to.equal('') + expect(deletedComment.inReplyToCommentId).to.be.null + expect(deletedComment.account).to.be.null + expect(deletedComment.totalReplies).to.equal(2) + expect(dateIsValid(deletedComment.createdAt as string)).to.be.true + expect(dateIsValid(deletedComment.updatedAt as string)).to.be.true + expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true + } + } + }) + + it('Should delete a remote thread by the origin server', async function () { + this.timeout(5000) + + const { data } = await servers[0].comments.listThreads({ videoId: videoUUID }) + const commentId = data.find(c => c.text === 'my super second comment').id + await servers[0].comments.delete({ videoId: videoUUID, commentId }) + + await waitJobs(servers) + }) + + it('Should have the threads marked as deleted on other servers too', async function () { + for (const server of servers) { + const body = await server.comments.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + { + const comment = body.data[0] + expect(comment.text).to.equal('') + expect(comment.isDeleted).to.be.true + expect(comment.createdAt).to.not.be.null + expect(comment.deletedAt).to.not.be.null + expect(comment.account).to.be.null + expect(comment.totalReplies).to.equal(0) + } + + { + const comment = body.data[1] + expect(comment.text).to.equal('') + expect(comment.isDeleted).to.be.true + expect(comment.createdAt).to.not.be.null + expect(comment.deletedAt).to.not.be.null + expect(comment.account).to.be.null + expect(comment.totalReplies).to.equal(2) + } + } + }) + + it('Should disable comments and download', async function () { + this.timeout(20000) + + const attributes = { + commentsEnabled: false, + downloadEnabled: false + } + + await servers[0].videos.update({ id: videoUUID, attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.commentsEnabled).to.be.false + expect(video.downloadEnabled).to.be.false + + const text = 'my super forbidden comment' + await server.comments.createThread({ videoId: videoUUID, text, expectedStatus: HttpStatusCode.CONFLICT_409 }) + } + }) + }) + + describe('With minimum parameters', function () { + it('Should upload and propagate the video', async function () { + this.timeout(120000) + + const path = '/api/v1/videos/upload' + + const req = request(servers[1].url) + .post(path) + .set('Accept', 'application/json') + .set('Authorization', 'Bearer ' + servers[1].accessToken) + .field('name', 'minimum parameters') + .field('privacy', '1') + .field('channelId', '1') + + await req.attach('videofile', buildAbsoluteFixturePath('video_short.webm')) + .expect(HttpStatusCode.OK_200) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + const video = data.find(v => v.name === 'minimum parameters') + + const isLocal = server.url === servers[1].url + const checkAttributes = { + name: 'minimum parameters', + category: null, + licence: null, + language: null, + nsfw: false, + description: null, + support: null, + account: { + name: 'root', + host: servers[1].host + }, + isLocal, + duration: 5, + commentsEnabled: true, + downloadEnabled: true, + tags: [], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal + }, + fixture: 'video_short.webm', + files: [ + { + resolution: 720, + size: 61000 + }, + { + resolution: 480, + size: 40000 + }, + { + resolution: 360, + size: 32000 + }, + { + resolution: 240, + size: 23000 + } + ] + } + await completeVideoCheck({ server, originServer: servers[1], videoUUID: video.uuid, attributes: checkAttributes }) + } + }) + }) + + describe('TMP directory', function () { + it('Should have an empty tmp directory', async function () { + for (const server of servers) { + await checkTmpIsEmpty(server) + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/resumable-upload.ts b/packages/tests/src/api/videos/resumable-upload.ts new file mode 100644 index 000000000..628e0298c --- /dev/null +++ b/packages/tests/src/api/videos/resumable-upload.ts @@ -0,0 +1,316 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists } from 'fs-extra/esm' +import { readdir, stat } from 'fs/promises' +import { join } from 'path' +import { HttpStatusCode, HttpStatusCodeType, VideoPrivacy } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath, sha1 } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +// Most classic resumable upload tests are done in other test suites + +describe('Test resumable upload', function () { + const path = '/api/v1/videos/upload-resumable' + const defaultFixture = 'video_short.mp4' + let server: PeerTubeServer + let rootId: number + let userAccessToken: string + let userChannelId: number + + async function buildSize (fixture: string, size?: number) { + if (size !== undefined) return size + + const baseFixture = buildAbsoluteFixturePath(fixture) + return (await stat(baseFixture)).size + } + + async function prepareUpload (options: { + channelId?: number + token?: string + size?: number + originalName?: string + lastModified?: number + } = {}) { + const { token, originalName, lastModified } = options + + const size = await buildSize(defaultFixture, options.size) + + const attributes = { + name: 'video', + channelId: options.channelId ?? server.store.channel.id, + privacy: VideoPrivacy.PUBLIC, + fixture: defaultFixture + } + + const mimetype = 'video/mp4' + + const res = await server.videos.prepareResumableUpload({ path, token, attributes, size, mimetype, originalName, lastModified }) + + return res.header['location'].split('?')[1] + } + + async function sendChunks (options: { + token?: string + pathUploadId: string + size?: number + expectedStatus?: HttpStatusCodeType + contentLength?: number + contentRange?: string + contentRangeBuilder?: (start: number, chunk: any) => string + digestBuilder?: (chunk: any) => string + }) { + const { token, pathUploadId, expectedStatus, contentLength, contentRangeBuilder, digestBuilder } = options + + const size = await buildSize(defaultFixture, options.size) + const absoluteFilePath = buildAbsoluteFixturePath(defaultFixture) + + return server.videos.sendResumableChunks({ + token, + path, + pathUploadId, + videoFilePath: absoluteFilePath, + size, + contentLength, + contentRangeBuilder, + digestBuilder, + expectedStatus + }) + } + + async function checkFileSize (uploadIdArg: string, expectedSize: number | null) { + const uploadId = uploadIdArg.replace(/^upload_id=/, '') + + const subPath = join('tmp', 'resumable-uploads', `${rootId}-${uploadId}.mp4`) + const filePath = server.servers.buildDirectory(subPath) + const exists = await pathExists(filePath) + + if (expectedSize === null) { + expect(exists).to.be.false + return + } + + expect(exists).to.be.true + + expect((await stat(filePath)).size).to.equal(expectedSize) + } + + async function countResumableUploads (wait?: number) { + const subPath = join('tmp', 'resumable-uploads') + const filePath = server.servers.buildDirectory(subPath) + await new Promise(resolve => setTimeout(resolve, wait)) + const files = await readdir(filePath) + return files.length + } + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + const body = await server.users.getMyInfo() + rootId = body.id + + { + userAccessToken = await server.users.generateUserAndToken('user1') + const { videoChannels } = await server.users.getMyInfo({ token: userAccessToken }) + userChannelId = videoChannels[0].id + } + + await server.users.update({ userId: rootId, videoQuota: 10_000_000 }) + }) + + describe('Directory cleaning', function () { + + it('Should correctly delete files after an upload', async function () { + const uploadId = await prepareUpload() + await sendChunks({ pathUploadId: uploadId }) + await server.videos.endResumableUpload({ path, pathUploadId: uploadId }) + + expect(await countResumableUploads()).to.equal(0) + }) + + it('Should correctly delete corrupt files', async function () { + const uploadId = await prepareUpload({ size: 8 * 1024 }) + await sendChunks({ pathUploadId: uploadId, size: 8 * 1024, expectedStatus: HttpStatusCode.UNPROCESSABLE_ENTITY_422 }) + + expect(await countResumableUploads(2000)).to.equal(0) + }) + + it('Should not delete files after an unfinished upload', async function () { + await prepareUpload() + + expect(await countResumableUploads()).to.equal(2) + }) + + it('Should not delete recent uploads', async function () { + await server.debug.sendCommand({ body: { command: 'remove-dandling-resumable-uploads' } }) + + expect(await countResumableUploads()).to.equal(2) + }) + + it('Should delete old uploads', async function () { + await server.debug.sendCommand({ body: { command: 'remove-dandling-resumable-uploads' } }) + + expect(await countResumableUploads()).to.equal(0) + }) + }) + + describe('Resumable upload and chunks', function () { + + it('Should accept the same amount of chunks', async function () { + const uploadId = await prepareUpload() + await sendChunks({ pathUploadId: uploadId }) + + await checkFileSize(uploadId, null) + }) + + it('Should not accept more chunks than expected', async function () { + const uploadId = await prepareUpload({ size: 100 }) + + await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.CONFLICT_409 }) + await checkFileSize(uploadId, 0) + }) + + it('Should not accept more chunks than expected with an invalid content length/content range', async function () { + const uploadId = await prepareUpload({ size: 1500 }) + + // Content length check can be different depending on the node version + try { + await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.CONFLICT_409, contentLength: 1000 }) + await checkFileSize(uploadId, 0) + } catch { + await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.BAD_REQUEST_400, contentLength: 1000 }) + await checkFileSize(uploadId, 0) + } + }) + + it('Should not accept more chunks than expected with an invalid content length', async function () { + const uploadId = await prepareUpload({ size: 500 }) + + const size = 1000 + + // Content length check seems to have changed in v16 + const expectedStatus = process.version.startsWith('v16') + ? HttpStatusCode.CONFLICT_409 + : HttpStatusCode.BAD_REQUEST_400 + + const contentRangeBuilder = (start: number) => `bytes ${start}-${start + size - 1}/${size}` + await sendChunks({ pathUploadId: uploadId, expectedStatus, contentRangeBuilder, contentLength: size }) + await checkFileSize(uploadId, 0) + }) + + it('Should be able to accept 2 PUT requests', async function () { + const uploadId = await prepareUpload() + + const result1 = await sendChunks({ pathUploadId: uploadId }) + const result2 = await sendChunks({ pathUploadId: uploadId }) + + expect(result1.body.video.uuid).to.exist + expect(result1.body.video.uuid).to.equal(result2.body.video.uuid) + + expect(result1.headers['x-resumable-upload-cached']).to.not.exist + expect(result2.headers['x-resumable-upload-cached']).to.equal('true') + + await checkFileSize(uploadId, null) + }) + + it('Should not have the same upload id with 2 different users', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + + const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken }) + const uploadId2 = await prepareUpload({ originalName, lastModified, channelId: userChannelId, token: userAccessToken }) + + expect(uploadId1).to.not.equal(uploadId2) + }) + + it('Should have the same upload id with the same user', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + + const uploadId1 = await prepareUpload({ originalName, lastModified }) + const uploadId2 = await prepareUpload({ originalName, lastModified }) + + expect(uploadId1).to.equal(uploadId2) + }) + + it('Should not cache a request with 2 different users', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + + const uploadId = await prepareUpload({ originalName, lastModified, token: server.accessToken }) + + await sendChunks({ pathUploadId: uploadId, token: server.accessToken }) + await sendChunks({ pathUploadId: uploadId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should not cache a request after a delete', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken }) + + await sendChunks({ pathUploadId: uploadId1 }) + await server.videos.endResumableUpload({ path, pathUploadId: uploadId1 }) + + const uploadId2 = await prepareUpload({ originalName, lastModified, token: server.accessToken }) + expect(uploadId1).to.equal(uploadId2) + + const result2 = await sendChunks({ pathUploadId: uploadId1 }) + expect(result2.headers['x-resumable-upload-cached']).to.not.exist + }) + + it('Should not cache after video deletion', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + + const uploadId1 = await prepareUpload({ originalName, lastModified }) + const result1 = await sendChunks({ pathUploadId: uploadId1 }) + await server.videos.remove({ id: result1.body.video.uuid }) + + const uploadId2 = await prepareUpload({ originalName, lastModified }) + const result2 = await sendChunks({ pathUploadId: uploadId2 }) + expect(result1.body.video.uuid).to.not.equal(result2.body.video.uuid) + + expect(result2.headers['x-resumable-upload-cached']).to.not.exist + + await checkFileSize(uploadId1, null) + await checkFileSize(uploadId2, null) + }) + + it('Should refuse an invalid digest', async function () { + const uploadId = await prepareUpload({ token: server.accessToken }) + + await sendChunks({ + pathUploadId: uploadId, + token: server.accessToken, + digestBuilder: () => 'sha=' + 'a'.repeat(40), + expectedStatus: 460 as any + }) + }) + + it('Should accept an appropriate digest', async function () { + const uploadId = await prepareUpload({ token: server.accessToken }) + + await sendChunks({ + pathUploadId: uploadId, + token: server.accessToken, + digestBuilder: (chunk: Buffer) => { + return 'sha1=' + sha1(chunk, 'base64') + } + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/single-server.ts b/packages/tests/src/api/videos/single-server.ts new file mode 100644 index 000000000..b87192a57 --- /dev/null +++ b/packages/tests/src/api/videos/single-server.ts @@ -0,0 +1,461 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { Video, VideoPrivacy } from '@peertube/peertube-models' +import { checkVideoFilesWereRemoved, completeVideoCheck } from '@tests/shared/videos.js' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test a single server', function () { + + function runSuite (mode: 'legacy' | 'resumable') { + let server: PeerTubeServer = null + let videoId: number | string + let videoId2: string + let videoUUID = '' + let videosListBase: any[] = null + + const getCheckAttributes = () => ({ + name: 'my super name', + category: 2, + licence: 6, + language: 'zh', + nsfw: true, + description: 'my super description', + support: 'my super support text', + account: { + name: 'root', + host: server.host + }, + isLocal: true, + duration: 5, + tags: [ 'tag1', 'tag2', 'tag3' ], + privacy: VideoPrivacy.PUBLIC, + commentsEnabled: true, + downloadEnabled: true, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal: true + }, + fixture: 'video_short.webm', + files: [ + { + resolution: 720, + size: 218910 + } + ] + }) + + const updateCheckAttributes = () => ({ + name: 'my super video updated', + category: 4, + licence: 2, + language: 'ar', + nsfw: false, + description: 'my super description updated', + support: 'my super support text updated', + account: { + name: 'root', + host: server.host + }, + isLocal: true, + tags: [ 'tagup1', 'tagup2' ], + privacy: VideoPrivacy.PUBLIC, + duration: 5, + commentsEnabled: false, + downloadEnabled: false, + channel: { + name: 'root_channel', + displayName: 'Main root channel', + description: '', + isLocal: true + }, + fixture: 'video_short3.webm', + files: [ + { + resolution: 720, + size: 292677 + } + ] + }) + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, {}) + + await setAccessTokensToServers([ server ]) + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(server) + }) + + it('Should list video categories', async function () { + const categories = await server.videos.getCategories() + expect(Object.keys(categories)).to.have.length.above(10) + + expect(categories[11]).to.equal('News & Politics') + }) + + it('Should list video licences', async function () { + const licences = await server.videos.getLicences() + expect(Object.keys(licences)).to.have.length.above(5) + + expect(licences[3]).to.equal('Attribution - No Derivatives') + }) + + it('Should list video languages', async function () { + const languages = await server.videos.getLanguages() + expect(Object.keys(languages)).to.have.length.above(5) + + expect(languages['ru']).to.equal('Russian') + }) + + it('Should list video privacies', async function () { + const privacies = await server.videos.getPrivacies() + expect(Object.keys(privacies)).to.have.length.at.least(3) + + expect(privacies[3]).to.equal('Private') + }) + + it('Should not have videos', async function () { + const { data, total } = await server.videos.list() + + expect(total).to.equal(0) + expect(data).to.be.an('array') + expect(data.length).to.equal(0) + }) + + it('Should upload the video', async function () { + const attributes = { + name: 'my super name', + category: 2, + nsfw: true, + licence: 6, + tags: [ 'tag1', 'tag2', 'tag3' ] + } + const video = await server.videos.upload({ attributes, mode }) + expect(video).to.not.be.undefined + expect(video.id).to.equal(1) + expect(video.uuid).to.have.length.above(5) + + videoId = video.id + videoUUID = video.uuid + }) + + it('Should get and seed the uploaded video', async function () { + this.timeout(5000) + + const { data, total } = await server.videos.list() + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data.length).to.equal(1) + + const video = data[0] + await completeVideoCheck({ server, originServer: server, videoUUID: video.uuid, attributes: getCheckAttributes() }) + }) + + it('Should get the video by UUID', async function () { + this.timeout(5000) + + const video = await server.videos.get({ id: videoUUID }) + await completeVideoCheck({ server, originServer: server, videoUUID: video.uuid, attributes: getCheckAttributes() }) + }) + + it('Should have the views updated', async function () { + this.timeout(20000) + + await server.views.simulateView({ id: videoId }) + await server.views.simulateView({ id: videoId }) + await server.views.simulateView({ id: videoId }) + + await wait(1500) + + await server.views.simulateView({ id: videoId }) + await server.views.simulateView({ id: videoId }) + + await wait(1500) + + await server.views.simulateView({ id: videoId }) + await server.views.simulateView({ id: videoId }) + + await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } }) + + const video = await server.videos.get({ id: videoId }) + expect(video.views).to.equal(3) + }) + + it('Should remove the video', async function () { + const video = await server.videos.get({ id: videoId }) + await server.videos.remove({ id: videoId }) + + await checkVideoFilesWereRemoved({ video, server }) + }) + + it('Should not have videos', async function () { + const { total, data } = await server.videos.list() + + expect(total).to.equal(0) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(0) + }) + + it('Should upload 6 videos', async function () { + this.timeout(120000) + + const videos = new Set([ + 'video_short.mp4', 'video_short.ogv', 'video_short.webm', + 'video_short1.webm', 'video_short2.webm', 'video_short3.webm' + ]) + + for (const video of videos) { + const attributes = { + name: video + ' name', + description: video + ' description', + category: 2, + licence: 1, + language: 'en', + nsfw: true, + tags: [ 'tag1', 'tag2', 'tag3' ], + fixture: video + } + + await server.videos.upload({ attributes, mode }) + } + }) + + it('Should have the correct durations', async function () { + const { total, data } = await server.videos.list() + + expect(total).to.equal(6) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(6) + + const videosByName: { [ name: string ]: Video } = {} + data.forEach(v => { videosByName[v.name] = v }) + + expect(videosByName['video_short.mp4 name'].duration).to.equal(5) + expect(videosByName['video_short.ogv name'].duration).to.equal(5) + expect(videosByName['video_short.webm name'].duration).to.equal(5) + expect(videosByName['video_short1.webm name'].duration).to.equal(10) + expect(videosByName['video_short2.webm name'].duration).to.equal(5) + expect(videosByName['video_short3.webm name'].duration).to.equal(5) + }) + + it('Should have the correct thumbnails', async function () { + const { data } = await server.videos.list() + + // For the next test + videosListBase = data + + for (const video of data) { + const videoName = video.name.replace(' name', '') + await testImageGeneratedByFFmpeg(server.url, videoName, video.thumbnailPath) + } + }) + + it('Should list only the two first videos', async function () { + const { total, data } = await server.videos.list({ start: 0, count: 2, sort: 'name' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(2) + expect(data[0].name).to.equal(videosListBase[0].name) + expect(data[1].name).to.equal(videosListBase[1].name) + }) + + it('Should list only the next three videos', async function () { + const { total, data } = await server.videos.list({ start: 2, count: 3, sort: 'name' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(3) + expect(data[0].name).to.equal(videosListBase[2].name) + expect(data[1].name).to.equal(videosListBase[3].name) + expect(data[2].name).to.equal(videosListBase[4].name) + }) + + it('Should list the last video', async function () { + const { total, data } = await server.videos.list({ start: 5, count: 6, sort: 'name' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(1) + expect(data[0].name).to.equal(videosListBase[5].name) + }) + + it('Should not have the total field', async function () { + const { total, data } = await server.videos.list({ start: 5, count: 6, sort: 'name', skipCount: true }) + + expect(total).to.not.exist + expect(data.length).to.equal(1) + expect(data[0].name).to.equal(videosListBase[5].name) + }) + + it('Should list and sort by name in descending order', async function () { + const { total, data } = await server.videos.list({ sort: '-name' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(6) + expect(data[0].name).to.equal('video_short.webm name') + expect(data[1].name).to.equal('video_short.ogv name') + expect(data[2].name).to.equal('video_short.mp4 name') + expect(data[3].name).to.equal('video_short3.webm name') + expect(data[4].name).to.equal('video_short2.webm name') + expect(data[5].name).to.equal('video_short1.webm name') + + videoId = data[3].uuid + videoId2 = data[5].uuid + }) + + it('Should list and sort by trending in descending order', async function () { + const { total, data } = await server.videos.list({ start: 0, count: 2, sort: '-trending' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(2) + }) + + it('Should list and sort by hotness in descending order', async function () { + const { total, data } = await server.videos.list({ start: 0, count: 2, sort: '-hot' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(2) + }) + + it('Should list and sort by best in descending order', async function () { + const { total, data } = await server.videos.list({ start: 0, count: 2, sort: '-best' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(2) + }) + + it('Should update a video', async function () { + const attributes = { + name: 'my super video updated', + category: 4, + licence: 2, + language: 'ar', + nsfw: false, + description: 'my super description updated', + commentsEnabled: false, + downloadEnabled: false, + tags: [ 'tagup1', 'tagup2' ] + } + await server.videos.update({ id: videoId, attributes }) + }) + + it('Should have the video updated', async function () { + this.timeout(60000) + + await waitJobs([ server ]) + + const video = await server.videos.get({ id: videoId }) + + await completeVideoCheck({ server, originServer: server, videoUUID: video.uuid, attributes: updateCheckAttributes() }) + }) + + it('Should update only the tags of a video', async function () { + const attributes = { + tags: [ 'supertag', 'tag1', 'tag2' ] + } + await server.videos.update({ id: videoId, attributes }) + + const video = await server.videos.get({ id: videoId }) + + await completeVideoCheck({ + server, + originServer: server, + videoUUID: video.uuid, + attributes: Object.assign(updateCheckAttributes(), attributes) + }) + }) + + it('Should update only the description of a video', async function () { + const attributes = { + description: 'hello everybody' + } + await server.videos.update({ id: videoId, attributes }) + + const video = await server.videos.get({ id: videoId }) + + await completeVideoCheck({ + server, + originServer: server, + videoUUID: video.uuid, + attributes: Object.assign(updateCheckAttributes(), { tags: [ 'supertag', 'tag1', 'tag2' ] }, attributes) + }) + }) + + it('Should like a video', async function () { + await server.videos.rate({ id: videoId, rating: 'like' }) + + const video = await server.videos.get({ id: videoId }) + + expect(video.likes).to.equal(1) + expect(video.dislikes).to.equal(0) + }) + + it('Should dislike the same video', async function () { + await server.videos.rate({ id: videoId, rating: 'dislike' }) + + const video = await server.videos.get({ id: videoId }) + + expect(video.likes).to.equal(0) + expect(video.dislikes).to.equal(1) + }) + + it('Should sort by originallyPublishedAt', async function () { + { + const now = new Date() + const attributes = { originallyPublishedAt: now.toISOString() } + await server.videos.update({ id: videoId, attributes }) + + const { data } = await server.videos.list({ sort: '-originallyPublishedAt' }) + const names = data.map(v => v.name) + + expect(names[0]).to.equal('my super video updated') + expect(names[1]).to.equal('video_short2.webm name') + expect(names[2]).to.equal('video_short1.webm name') + expect(names[3]).to.equal('video_short.webm name') + expect(names[4]).to.equal('video_short.ogv name') + expect(names[5]).to.equal('video_short.mp4 name') + } + + { + const now = new Date() + const attributes = { originallyPublishedAt: now.toISOString() } + await server.videos.update({ id: videoId2, attributes }) + + const { data } = await server.videos.list({ sort: '-originallyPublishedAt' }) + const names = data.map(v => v.name) + + expect(names[0]).to.equal('video_short1.webm name') + expect(names[1]).to.equal('my super video updated') + expect(names[2]).to.equal('video_short2.webm name') + expect(names[3]).to.equal('video_short.webm name') + expect(names[4]).to.equal('video_short.ogv name') + expect(names[5]).to.equal('video_short.mp4 name') + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) + } + + describe('Legacy upload', function () { + runSuite('legacy') + }) + + describe('Resumable upload', function () { + runSuite('resumable') + }) +}) diff --git a/packages/tests/src/api/videos/video-captions.ts b/packages/tests/src/api/videos/video-captions.ts new file mode 100644 index 000000000..027022549 --- /dev/null +++ b/packages/tests/src/api/videos/video-captions.ts @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { testCaptionFile } from '@tests/shared/captions.js' +import { checkVideoFilesWereRemoved } from '@tests/shared/videos.js' + +describe('Test video captions', function () { + const uuidRegex = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + + let servers: PeerTubeServer[] + let videoUUID: string + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await doubleFollow(servers[0], servers[1]) + + await waitJobs(servers) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video name' } }) + videoUUID = uuid + + await waitJobs(servers) + }) + + it('Should list the captions and return an empty list', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should create two new captions', async function () { + this.timeout(30000) + + await servers[0].captions.add({ + language: 'ar', + videoId: videoUUID, + fixture: 'subtitle-good1.vtt' + }) + + await servers[0].captions.add({ + language: 'zh', + videoId: videoUUID, + fixture: 'subtitle-good2.vtt', + mimeType: 'application/octet-stream' + }) + + await waitJobs(servers) + }) + + it('Should list these uploaded captions', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + const caption1 = body.data[0] + expect(caption1.language.id).to.equal('ar') + expect(caption1.language.label).to.equal('Arabic') + expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$')) + await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.') + + const caption2 = body.data[1] + expect(caption2.language.id).to.equal('zh') + expect(caption2.language.label).to.equal('Chinese') + expect(caption2.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-zh.vtt$')) + await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.') + } + }) + + it('Should replace an existing caption', async function () { + this.timeout(30000) + + await servers[0].captions.add({ + language: 'ar', + videoId: videoUUID, + fixture: 'subtitle-good2.vtt' + }) + + await waitJobs(servers) + }) + + it('Should have this caption updated', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + const caption1 = body.data[0] + expect(caption1.language.id).to.equal('ar') + expect(caption1.language.label).to.equal('Arabic') + expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$')) + await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.') + } + }) + + it('Should replace an existing caption with a srt file and convert it', async function () { + this.timeout(30000) + + await servers[0].captions.add({ + language: 'ar', + videoId: videoUUID, + fixture: 'subtitle-good.srt' + }) + + await waitJobs(servers) + + // Cache invalidation + await wait(3000) + }) + + it('Should have this caption updated and converted', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + const caption1 = body.data[0] + expect(caption1.language.id).to.equal('ar') + expect(caption1.language.label).to.equal('Arabic') + expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$')) + + const expected = 'WEBVTT FILE\r\n' + + '\r\n' + + '1\r\n' + + '00:00:01.600 --> 00:00:04.200\r\n' + + 'English (US)\r\n' + + '\r\n' + + '2\r\n' + + '00:00:05.900 --> 00:00:07.999\r\n' + + 'This is a subtitle in American English\r\n' + + '\r\n' + + '3\r\n' + + '00:00:10.000 --> 00:00:14.000\r\n' + + 'Adding subtitles is very easy to do\r\n' + await testCaptionFile(server.url, caption1.captionPath, expected) + } + }) + + it('Should remove one caption', async function () { + this.timeout(30000) + + await servers[0].captions.delete({ videoId: videoUUID, language: 'ar' }) + + await waitJobs(servers) + }) + + it('Should only list the caption that was not deleted', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const caption = body.data[0] + + expect(caption.language.id).to.equal('zh') + expect(caption.language.label).to.equal('Chinese') + expect(caption.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-zh.vtt$')) + await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.') + } + }) + + it('Should remove the video, and thus all video captions', async function () { + const video = await servers[0].videos.get({ id: videoUUID }) + const { data: captions } = await servers[0].captions.list({ videoId: videoUUID }) + + await servers[0].videos.remove({ id: videoUUID }) + + await checkVideoFilesWereRemoved({ server: servers[0], video, captions }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-change-ownership.ts b/packages/tests/src/api/videos/video-change-ownership.ts new file mode 100644 index 000000000..717c37469 --- /dev/null +++ b/packages/tests/src/api/videos/video-change-ownership.ts @@ -0,0 +1,314 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + ChangeOwnershipCommand, + cleanupTests, + createMultipleServers, + createSingleServer, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' + +describe('Test video change ownership - nominal', function () { + let servers: PeerTubeServer[] = [] + + const firstUser = 'first' + const secondUser = 'second' + + let firstUserToken = '' + let firstUserChannelId: number + + let secondUserToken = '' + let secondUserChannelId: number + + let lastRequestId: number + + let liveId: number + + let command: ChangeOwnershipCommand + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + transcoding: { + enabled: false + }, + live: { + enabled: true + } + } + }) + + firstUserToken = await servers[0].users.generateUserAndToken(firstUser) + secondUserToken = await servers[0].users.generateUserAndToken(secondUser) + + { + const { videoChannels } = await servers[0].users.getMyInfo({ token: firstUserToken }) + firstUserChannelId = videoChannels[0].id + } + + { + const { videoChannels } = await servers[0].users.getMyInfo({ token: secondUserToken }) + secondUserChannelId = videoChannels[0].id + } + + { + const attributes = { + name: 'my super name', + description: 'my super description' + } + const { id } = await servers[0].videos.upload({ token: firstUserToken, attributes }) + + servers[0].store.videoCreated = await servers[0].videos.get({ id }) + } + + { + const attributes = { name: 'live', channelId: firstUserChannelId, privacy: VideoPrivacy.PUBLIC } + const video = await servers[0].live.create({ token: firstUserToken, fields: attributes }) + + liveId = video.id + } + + command = servers[0].changeOwnership + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should not have video change ownership', async function () { + { + const body = await command.list({ token: firstUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + + { + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + }) + + it('Should send a request to change ownership of a video', async function () { + this.timeout(15000) + + await command.create({ token: firstUserToken, videoId: servers[0].store.videoCreated.id, username: secondUser }) + }) + + it('Should only return a request to change ownership for the second user', async function () { + { + const body = await command.list({ token: firstUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + + { + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(1) + + lastRequestId = body.data[0].id + } + }) + + it('Should accept the same change ownership request without crashing', async function () { + await command.create({ token: firstUserToken, videoId: servers[0].store.videoCreated.id, username: secondUser }) + }) + + it('Should not create multiple change ownership requests while one is waiting', async function () { + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(1) + }) + + it('Should not be possible to refuse the change of ownership from first user', async function () { + await command.refuse({ token: firstUserToken, ownershipId: lastRequestId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should be possible to refuse the change of ownership from second user', async function () { + await command.refuse({ token: secondUserToken, ownershipId: lastRequestId }) + }) + + it('Should send a new request to change ownership of a video', async function () { + this.timeout(15000) + + await command.create({ token: firstUserToken, videoId: servers[0].store.videoCreated.id, username: secondUser }) + }) + + it('Should return two requests to change ownership for the second user', async function () { + { + const body = await command.list({ token: firstUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + + { + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(2) + + lastRequestId = body.data[0].id + } + }) + + it('Should not be possible to accept the change of ownership from first user', async function () { + await command.accept({ + token: firstUserToken, + ownershipId: lastRequestId, + channelId: secondUserChannelId, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should be possible to accept the change of ownership from second user', async function () { + await command.accept({ token: secondUserToken, ownershipId: lastRequestId, channelId: secondUserChannelId }) + + await waitJobs(servers) + }) + + it('Should have the channel of the video updated', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: servers[0].store.videoCreated.uuid }) + + expect(video.name).to.equal('my super name') + expect(video.channel.displayName).to.equal('Main second channel') + expect(video.channel.name).to.equal('second_channel') + } + }) + + it('Should send a request to change ownership of a live', async function () { + this.timeout(15000) + + await command.create({ token: firstUserToken, videoId: liveId, username: secondUser }) + + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(3) + expect(body.data.length).to.equal(3) + + lastRequestId = body.data[0].id + }) + + it('Should accept a live ownership change', async function () { + this.timeout(20000) + + await command.accept({ token: secondUserToken, ownershipId: lastRequestId, channelId: secondUserChannelId }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: servers[0].store.videoCreated.uuid }) + + expect(video.name).to.equal('my super name') + expect(video.channel.displayName).to.equal('Main second channel') + expect(video.channel.name).to.equal('second_channel') + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) + +describe('Test video change ownership - quota too small', function () { + let server: PeerTubeServer + const firstUser = 'first' + const secondUser = 'second' + + let firstUserToken = '' + let secondUserToken = '' + let lastRequestId: number + + before(async function () { + this.timeout(50000) + + // Run one server + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + await server.users.create({ username: secondUser, videoQuota: 10 }) + + firstUserToken = await server.users.generateUserAndToken(firstUser) + secondUserToken = await server.login.getAccessToken(secondUser) + + // Upload some videos on the server + const attributes = { + name: 'my super name', + description: 'my super description' + } + await server.videos.upload({ token: firstUserToken, attributes }) + + await waitJobs(server) + + const { data } = await server.videos.list() + expect(data.length).to.equal(1) + + server.store.videoCreated = data.find(video => video.name === 'my super name') + }) + + it('Should send a request to change ownership of a video', async function () { + this.timeout(15000) + + await server.changeOwnership.create({ token: firstUserToken, videoId: server.store.videoCreated.id, username: secondUser }) + }) + + it('Should only return a request to change ownership for the second user', async function () { + { + const body = await server.changeOwnership.list({ token: firstUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + + { + const body = await server.changeOwnership.list({ token: secondUserToken }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(1) + + lastRequestId = body.data[0].id + } + }) + + it('Should not be possible to accept the change of ownership from second user because of exceeded quota', async function () { + const { videoChannels } = await server.users.getMyInfo({ token: secondUserToken }) + const channelId = videoChannels[0].id + + await server.changeOwnership.accept({ + token: secondUserToken, + ownershipId: lastRequestId, + channelId, + expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413 + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-channel-syncs.ts b/packages/tests/src/api/videos/video-channel-syncs.ts new file mode 100644 index 000000000..54212bcb5 --- /dev/null +++ b/packages/tests/src/api/videos/video-channel-syncs.ts @@ -0,0 +1,321 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { VideoChannelSyncState, VideoInclude, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + getServerImportConfig, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' + +describe('Test channel synchronizations', function () { + if (areHttpImportTestsDisabled()) return + + function runSuite (mode: 'youtube-dl' | 'yt-dlp') { + + describe('Sync using ' + mode, function () { + let servers: PeerTubeServer[] + let sqlCommands: SQLCommand[] = [] + + let startTestDate: Date + + let rootChannelSyncId: number + const userInfo = { + accessToken: '', + username: 'user1', + channelName: 'user1_channel', + channelId: -1, + syncId: -1 + } + + async function changeDateForSync (channelSyncId: number, newDate: string) { + await sqlCommands[0].updateQuery( + `UPDATE "videoChannelSync" ` + + `SET "createdAt"='${newDate}', "lastSyncAt"='${newDate}' ` + + `WHERE id=${channelSyncId}` + ) + } + + async function listAllVideosOfChannel (channelName: string) { + return servers[0].videos.listByChannel({ + handle: channelName, + include: VideoInclude.NOT_PUBLISHED_STATE + }) + } + + async function forceSyncAll (videoChannelSyncId: number, fromDate = '1970-01-01') { + await changeDateForSync(videoChannelSyncId, fromDate) + + await servers[0].debug.sendCommand({ + body: { + command: 'process-video-channel-sync-latest' + } + }) + + await waitJobs(servers) + } + + before(async function () { + this.timeout(240_000) + + startTestDate = new Date() + + servers = await createMultipleServers(2, getServerImportConfig(mode)) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultChannelAvatar(servers) + await setDefaultAccountAvatar(servers) + + await servers[0].config.enableChannelSync() + + { + userInfo.accessToken = await servers[0].users.generateUserAndToken(userInfo.username) + + const { videoChannels } = await servers[0].users.getMyInfo({ token: userInfo.accessToken }) + userInfo.channelId = videoChannels[0].id + } + + sqlCommands = servers.map(s => new SQLCommand(s)) + }) + + it('Should fetch the latest channel videos of a remote channel', async function () { + this.timeout(120_000) + + { + const { video } = await servers[0].imports.importVideo({ + attributes: { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + targetUrl: FIXTURE_URLS.youtube + } + }) + + expect(video.name).to.equal('small video - youtube') + expect(video.waitTranscoding).to.be.true + + const { total } = await listAllVideosOfChannel('root_channel') + expect(total).to.equal(1) + } + + const { videoChannelSync } = await servers[0].channelSyncs.create({ + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelId: servers[0].store.channel.id + } + }) + rootChannelSyncId = videoChannelSync.id + + await forceSyncAll(rootChannelSyncId) + + { + const { total, data } = await listAllVideosOfChannel('root_channel') + expect(total).to.equal(2) + expect(data[0].name).to.equal('test') + expect(data[0].waitTranscoding).to.be.true + } + }) + + it('Should add another synchronization', async function () { + const externalChannelUrl = FIXTURE_URLS.youtubeChannel + '?foo=bar' + + const { videoChannelSync } = await servers[0].channelSyncs.create({ + attributes: { + externalChannelUrl, + videoChannelId: servers[0].store.channel.id + } + }) + + expect(videoChannelSync.externalChannelUrl).to.equal(externalChannelUrl) + expect(videoChannelSync.channel.id).to.equal(servers[0].store.channel.id) + expect(videoChannelSync.channel.name).to.equal('root_channel') + expect(videoChannelSync.state.id).to.equal(VideoChannelSyncState.WAITING_FIRST_RUN) + expect(new Date(videoChannelSync.createdAt)).to.be.above(startTestDate).and.to.be.at.most(new Date()) + }) + + it('Should add a synchronization for another user', async function () { + const { videoChannelSync } = await servers[0].channelSyncs.create({ + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubeChannel + '?baz=qux', + videoChannelId: userInfo.channelId + }, + token: userInfo.accessToken + }) + userInfo.syncId = videoChannelSync.id + }) + + it('Should not import a channel if not asked', async function () { + await waitJobs(servers) + + const { data } = await servers[0].channelSyncs.listByAccount({ accountName: userInfo.username }) + + expect(data[0].state).to.contain({ + id: VideoChannelSyncState.WAITING_FIRST_RUN, + label: 'Waiting first run' + }) + }) + + it('Should only fetch the videos newer than the creation date', async function () { + this.timeout(120_000) + + await forceSyncAll(userInfo.syncId, '2019-03-01') + + const { data, total } = await listAllVideosOfChannel(userInfo.channelName) + + expect(total).to.equal(1) + expect(data[0].name).to.equal('test') + }) + + it('Should list channel synchronizations', async function () { + // Root + { + const { total, data } = await servers[0].channelSyncs.listByAccount({ accountName: 'root' }) + expect(total).to.equal(2) + + expect(data[0]).to.deep.contain({ + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + state: { + id: VideoChannelSyncState.SYNCED, + label: 'Synchronized' + } + }) + + expect(new Date(data[0].lastSyncAt)).to.be.greaterThan(startTestDate) + + expect(data[0].channel).to.contain({ id: servers[0].store.channel.id }) + expect(data[1]).to.contain({ externalChannelUrl: FIXTURE_URLS.youtubeChannel + '?foo=bar' }) + } + + // User + { + const { total, data } = await servers[0].channelSyncs.listByAccount({ accountName: userInfo.username }) + expect(total).to.equal(1) + expect(data[0]).to.deep.contain({ + externalChannelUrl: FIXTURE_URLS.youtubeChannel + '?baz=qux', + state: { + id: VideoChannelSyncState.SYNCED, + label: 'Synchronized' + } + }) + } + }) + + it('Should list imports of a channel synchronization', async function () { + const { total, data } = await servers[0].imports.getMyVideoImports({ videoChannelSyncId: rootChannelSyncId }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].video.name).to.equal('test') + }) + + it('Should remove user\'s channel synchronizations', async function () { + await servers[0].channelSyncs.delete({ channelSyncId: userInfo.syncId }) + + const { total } = await servers[0].channelSyncs.listByAccount({ accountName: userInfo.username }) + expect(total).to.equal(0) + }) + + // FIXME: youtube-dl/yt-dlp doesn't work when speicifying a port after the hostname + // it('Should import a remote PeerTube channel', async function () { + // this.timeout(240_000) + + // await servers[1].videos.quickUpload({ name: 'remote 1' }) + // await waitJobs(servers) + + // const { videoChannelSync } = await servers[0].channelSyncs.create({ + // attributes: { + // externalChannelUrl: servers[1].url + '/c/root_channel', + // videoChannelId: userInfo.channelId + // }, + // token: userInfo.accessToken + // }) + // await servers[0].channels.importVideos({ + // channelName: userInfo.channelName, + // externalChannelUrl: servers[1].url + '/c/root_channel', + // videoChannelSyncId: videoChannelSync.id, + // token: userInfo.accessToken + // }) + + // await waitJobs(servers) + + // const { data, total } = await servers[0].videos.listByChannel({ + // handle: userInfo.channelName, + // include: VideoInclude.NOT_PUBLISHED_STATE + // }) + + // expect(total).to.equal(2) + // expect(data[0].name).to.equal('remote 1') + // }) + + // it('Should keep synced a remote PeerTube channel', async function () { + // this.timeout(240_000) + + // await servers[1].videos.quickUpload({ name: 'remote 2' }) + // await waitJobs(servers) + + // await servers[0].debug.sendCommand({ + // body: { + // command: 'process-video-channel-sync-latest' + // } + // }) + + // await waitJobs(servers) + + // const { data, total } = await servers[0].videos.listByChannel({ + // handle: userInfo.channelName, + // include: VideoInclude.NOT_PUBLISHED_STATE + // }) + // expect(total).to.equal(2) + // expect(data[0].name).to.equal('remote 2') + // }) + + it('Should fetch the latest videos of a youtube playlist', async function () { + this.timeout(120_000) + + const { id: channelId } = await servers[0].channels.create({ + attributes: { + name: 'channel2' + } + }) + + const { videoChannelSync: { id: videoChannelSyncId } } = await servers[0].channelSyncs.create({ + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubePlaylist, + videoChannelId: channelId + } + }) + + await forceSyncAll(videoChannelSyncId) + + { + + const { total, data } = await listAllVideosOfChannel('channel2') + expect(total).to.equal(2) + expect(data[0].name).to.equal('test') + expect(data[1].name).to.equal('small video - youtube') + } + }) + + after(async function () { + for (const sqlCommand of sqlCommands) { + await sqlCommand.cleanup() + } + + await cleanupTests(servers) + }) + }) + } + + // FIXME: suite is broken with youtube-dl + // runSuite('youtube-dl') + runSuite('yt-dlp') +}) diff --git a/packages/tests/src/api/videos/video-channels.ts b/packages/tests/src/api/videos/video-channels.ts new file mode 100644 index 000000000..64b1b9315 --- /dev/null +++ b/packages/tests/src/api/videos/video-channels.ts @@ -0,0 +1,556 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { basename } from 'path' +import { ACTOR_IMAGES_SIZE } from '@peertube/peertube-server/server/initializers/constants.js' +import { testFileExistsOrNot, testImage } from '@tests/shared/checks.js' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { wait } from '@peertube/peertube-core-utils' +import { ActorImageType, User, VideoChannel } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +async function findChannel (server: PeerTubeServer, channelId: number) { + const body = await server.channels.list({ sort: '-name' }) + + return body.data.find(c => c.id === channelId) +} + +describe('Test video channels', function () { + let servers: PeerTubeServer[] + let sqlCommands: SQLCommand[] = [] + + let userInfo: User + let secondVideoChannelId: number + let totoChannel: number + let videoUUID: string + let accountName: string + let secondUserChannelName: string + + const avatarPaths: { [ port: number ]: string } = {} + const bannerPaths: { [ port: number ]: string } = {} + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + await doubleFollow(servers[0], servers[1]) + + sqlCommands = servers.map(s => new SQLCommand(s)) + }) + + it('Should have one video channel (created with root)', async () => { + const body = await servers[0].channels.list({ start: 0, count: 2 }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + }) + + it('Should create another video channel', async function () { + this.timeout(30000) + + { + const videoChannel = { + name: 'second_video_channel', + displayName: 'second video channel', + description: 'super video channel description', + support: 'super video channel support text' + } + const created = await servers[0].channels.create({ attributes: videoChannel }) + secondVideoChannelId = created.id + } + + // The channel is 1 is propagated to servers 2 + { + const attributes = { name: 'my video name', channelId: secondVideoChannelId, support: 'video support field' } + const { uuid } = await servers[0].videos.upload({ attributes }) + videoUUID = uuid + } + + await waitJobs(servers) + }) + + it('Should have two video channels when getting my information', async () => { + userInfo = await servers[0].users.getMyInfo() + + expect(userInfo.videoChannels).to.be.an('array') + expect(userInfo.videoChannels).to.have.lengthOf(2) + + const videoChannels = userInfo.videoChannels + expect(videoChannels[0].name).to.equal('root_channel') + expect(videoChannels[0].displayName).to.equal('Main root channel') + + expect(videoChannels[1].name).to.equal('second_video_channel') + expect(videoChannels[1].displayName).to.equal('second video channel') + expect(videoChannels[1].description).to.equal('super video channel description') + expect(videoChannels[1].support).to.equal('super video channel support text') + + accountName = userInfo.account.name + '@' + userInfo.account.host + }) + + it('Should have two video channels when getting account channels on server 1', async function () { + const body = await servers[0].channels.listByAccount({ accountName }) + expect(body.total).to.equal(2) + + const videoChannels = body.data + + expect(videoChannels).to.be.an('array') + expect(videoChannels).to.have.lengthOf(2) + + expect(videoChannels[0].name).to.equal('root_channel') + expect(videoChannels[0].displayName).to.equal('Main root channel') + + expect(videoChannels[1].name).to.equal('second_video_channel') + expect(videoChannels[1].displayName).to.equal('second video channel') + expect(videoChannels[1].description).to.equal('super video channel description') + expect(videoChannels[1].support).to.equal('super video channel support text') + }) + + it('Should paginate and sort account channels', async function () { + { + const body = await servers[0].channels.listByAccount({ + accountName, + start: 0, + count: 1, + sort: 'createdAt' + }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(1) + + const videoChannel: VideoChannel = body.data[0] + expect(videoChannel.name).to.equal('root_channel') + } + + { + const body = await servers[0].channels.listByAccount({ + accountName, + start: 0, + count: 1, + sort: '-createdAt' + }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('second_video_channel') + } + + { + const body = await servers[0].channels.listByAccount({ + accountName, + start: 1, + count: 1, + sort: '-createdAt' + }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('root_channel') + } + }) + + it('Should have one video channel when getting account channels on server 2', async function () { + const body = await servers[1].channels.listByAccount({ accountName }) + + 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.name).to.equal('second_video_channel') + expect(videoChannel.displayName).to.equal('second video channel') + expect(videoChannel.description).to.equal('super video channel description') + expect(videoChannel.support).to.equal('super video channel support text') + }) + + it('Should list video channels', async function () { + const body = await servers[0].channels.list({ start: 1, count: 1, sort: '-name' }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('root_channel') + expect(body.data[0].displayName).to.equal('Main root channel') + }) + + it('Should update video channel', async function () { + this.timeout(15000) + + const videoChannelAttributes = { + displayName: 'video channel updated', + description: 'video channel description updated', + support: 'support updated' + } + + await servers[0].channels.update({ channelName: 'second_video_channel', attributes: videoChannelAttributes }) + + await waitJobs(servers) + }) + + it('Should have video channel updated', async function () { + for (const server of servers) { + const body = await server.channels.list({ start: 0, count: 1, sort: '-name' }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + + expect(body.data[0].name).to.equal('second_video_channel') + expect(body.data[0].displayName).to.equal('video channel updated') + expect(body.data[0].description).to.equal('video channel description updated') + expect(body.data[0].support).to.equal('support updated') + } + }) + + it('Should not have updated the video support field', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.support).to.equal('video support field') + } + }) + + it('Should update another accounts video channel', async function () { + this.timeout(15000) + + const result = await servers[0].users.generate('second_user') + secondUserChannelName = result.userChannelName + + await servers[0].videos.quickUpload({ name: 'video', token: result.token }) + + const videoChannelAttributes = { + displayName: 'video channel updated', + description: 'video channel description updated', + support: 'support updated' + } + + await servers[0].channels.update({ channelName: secondUserChannelName, attributes: videoChannelAttributes }) + + await waitJobs(servers) + }) + + it('Should have another accounts video channel updated', async function () { + for (const server of servers) { + const body = await server.channels.get({ channelName: `${secondUserChannelName}@${servers[0].host}` }) + + expect(body.displayName).to.equal('video channel updated') + expect(body.description).to.equal('video channel description updated') + expect(body.support).to.equal('support updated') + } + }) + + it('Should update the channel support field and update videos too', async function () { + this.timeout(35000) + + const videoChannelAttributes = { + support: 'video channel support text updated', + bulkVideosSupportUpdate: true + } + + await servers[0].channels.update({ channelName: 'second_video_channel', attributes: videoChannelAttributes }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.support).to.equal(videoChannelAttributes.support) + } + }) + + it('Should update video channel avatar', async function () { + this.timeout(15000) + + const fixture = 'avatar.png' + + await servers[0].channels.updateImage({ + channelName: 'second_video_channel', + fixture, + type: 'avatar' + }) + + await waitJobs(servers) + + for (let i = 0; i < servers.length; i++) { + const server = servers[i] + + const videoChannel = await findChannel(server, secondVideoChannelId) + const expectedSizes = ACTOR_IMAGES_SIZE[ActorImageType.AVATAR] + + expect(videoChannel.avatars.length).to.equal(expectedSizes.length, 'Expected avatars to be generated in all sizes') + + for (const avatar of videoChannel.avatars) { + avatarPaths[server.port] = avatar.path + await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatarPaths[server.port], '.png') + await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), true) + + const row = await sqlCommands[i].getActorImage(basename(avatarPaths[server.port])) + + expect(expectedSizes.some(({ height, width }) => row.height === height && row.width === width)).to.equal(true) + } + } + }) + + it('Should update video channel banner', async function () { + this.timeout(15000) + + const fixture = 'banner.jpg' + + await servers[0].channels.updateImage({ + channelName: 'second_video_channel', + fixture, + type: 'banner' + }) + + await waitJobs(servers) + + for (let i = 0; i < servers.length; i++) { + const server = servers[i] + + const videoChannel = await server.channels.get({ channelName: 'second_video_channel@' + servers[0].host }) + + bannerPaths[server.port] = videoChannel.banners[0].path + await testImage(server.url, 'banner-resized', bannerPaths[server.port]) + await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), true) + + const row = await sqlCommands[i].getActorImage(basename(bannerPaths[server.port])) + expect(row.height).to.equal(ACTOR_IMAGES_SIZE[ActorImageType.BANNER][0].height) + expect(row.width).to.equal(ACTOR_IMAGES_SIZE[ActorImageType.BANNER][0].width) + } + }) + + it('Should still correctly list channels', async function () { + { + const body = await servers[0].channels.list({ start: 1, count: 1, sort: 'createdAt' }) + + expect(body.total).to.equal(3) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('second_video_channel') + } + + { + const body = await servers[0].channels.listByAccount({ accountName, start: 1, count: 1, sort: 'createdAt' }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('second_video_channel') + } + }) + + it('Should delete the video channel avatar', async function () { + this.timeout(15000) + await servers[0].channels.deleteImage({ channelName: 'second_video_channel', type: 'avatar' }) + + await waitJobs(servers) + + for (const server of servers) { + const videoChannel = await findChannel(server, secondVideoChannelId) + await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), false) + + expect(videoChannel.avatars).to.be.empty + } + }) + + it('Should delete the video channel banner', async function () { + this.timeout(15000) + + await servers[0].channels.deleteImage({ channelName: 'second_video_channel', type: 'banner' }) + + await waitJobs(servers) + + for (const server of servers) { + const videoChannel = await findChannel(server, secondVideoChannelId) + await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), false) + + expect(videoChannel.banners).to.be.empty + } + }) + + it('Should list the second video channel videos', async function () { + for (const server of servers) { + const channelURI = 'second_video_channel@' + servers[0].host + const { total, data } = await server.videos.listByChannel({ handle: channelURI }) + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('my video name') + } + }) + + it('Should change the video channel of a video', async function () { + await servers[0].videos.update({ id: videoUUID, attributes: { channelId: servers[0].store.channel.id } }) + + await waitJobs(servers) + }) + + it('Should list the first video channel videos', async function () { + for (const server of servers) { + { + const secondChannelURI = 'second_video_channel@' + servers[0].host + const { total } = await server.videos.listByChannel({ handle: secondChannelURI }) + expect(total).to.equal(0) + } + + { + const channelURI = 'root_channel@' + servers[0].host + const { total, data } = await server.videos.listByChannel({ handle: channelURI }) + expect(total).to.equal(1) + + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('my video name') + } + } + }) + + it('Should delete video channel', async function () { + await servers[0].channels.delete({ channelName: 'second_video_channel' }) + }) + + it('Should have video channel deleted', async function () { + const body = await servers[0].channels.list({ start: 0, count: 10, sort: 'createdAt' }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].displayName).to.equal('Main root channel') + expect(body.data[1].displayName).to.equal('video channel updated') + }) + + it('Should create the main channel with a suffix if there is a conflict', async function () { + { + const videoChannel = { name: 'toto_channel', displayName: 'My toto channel' } + const created = await servers[0].channels.create({ attributes: videoChannel }) + totoChannel = created.id + } + + { + await servers[0].users.create({ username: 'toto', password: 'password' }) + const accessToken = await servers[0].login.getAccessToken({ username: 'toto', password: 'password' }) + + const { videoChannels } = await servers[0].users.getMyInfo({ token: accessToken }) + expect(videoChannels[0].name).to.equal('toto_channel-1') + } + }) + + it('Should report correct channel views per days', async function () { + { + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + + for (const channel of data) { + expect(channel).to.haveOwnProperty('viewsPerDay') + expect(channel.viewsPerDay).to.have.length(30 + 1) // daysPrior + today + + for (const v of channel.viewsPerDay) { + expect(v.date).to.be.an('string') + expect(v.views).to.equal(0) + } + } + } + + { + // video has been posted on channel servers[0].store.videoChannel.id since last update + await servers[0].views.simulateView({ id: videoUUID, xForwardedFor: '0.0.0.1,127.0.0.1' }) + await servers[0].views.simulateView({ id: videoUUID, xForwardedFor: '0.0.0.2,127.0.0.1' }) + + // Wait the repeatable job + await wait(8000) + + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + const channelWithView = data.find(channel => channel.id === servers[0].store.channel.id) + expect(channelWithView.viewsPerDay.slice(-1)[0].views).to.equal(2) + } + }) + + it('Should report correct total views count', async function () { + // check if there's the property + { + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + + for (const channel of data) { + expect(channel).to.haveOwnProperty('totalViews') + expect(channel.totalViews).to.be.a('number') + } + } + + // Check if the totalViews count can be updated + { + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + const channelWithView = data.find(channel => channel.id === servers[0].store.channel.id) + expect(channelWithView.totalViews).to.equal(2) + } + }) + + it('Should report correct videos count', async function () { + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + + const totoChannel = data.find(c => c.name === 'toto_channel') + const rootChannel = data.find(c => c.name === 'root_channel') + + expect(rootChannel.videosCount).to.equal(1) + expect(totoChannel.videosCount).to.equal(0) + }) + + it('Should search among account video channels', async function () { + { + const body = await servers[0].channels.listByAccount({ accountName, search: 'root' }) + expect(body.total).to.equal(1) + + const channels = body.data + expect(channels).to.have.lengthOf(1) + } + + { + const body = await servers[0].channels.listByAccount({ accountName, search: 'does not exist' }) + expect(body.total).to.equal(0) + + const channels = body.data + expect(channels).to.have.lengthOf(0) + } + }) + + it('Should list channels by updatedAt desc if a video has been uploaded', async function () { + this.timeout(30000) + + await servers[0].videos.upload({ attributes: { channelId: totoChannel } }) + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.channels.listByAccount({ accountName, sort: '-updatedAt' }) + + expect(data[0].name).to.equal('toto_channel') + expect(data[1].name).to.equal('root_channel') + } + + await servers[0].videos.upload({ attributes: { channelId: servers[0].store.channel.id } }) + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.channels.listByAccount({ accountName, sort: '-updatedAt' }) + + expect(data[0].name).to.equal('root_channel') + expect(data[1].name).to.equal('toto_channel') + } + }) + + after(async function () { + for (const sqlCommand of sqlCommands) { + await sqlCommand.cleanup() + } + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-comments.ts b/packages/tests/src/api/videos/video-comments.ts new file mode 100644 index 000000000..f17db9979 --- /dev/null +++ b/packages/tests/src/api/videos/video-comments.ts @@ -0,0 +1,335 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { dateIsValid, testImage } from '@tests/shared/checks.js' +import { + cleanupTests, + CommentsCommand, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar +} from '@peertube/peertube-server-commands' + +describe('Test video comments', function () { + let server: PeerTubeServer + let videoId: number + let videoUUID: string + let threadId: number + let replyToDeleteId: number + + let userAccessTokenServer1: string + + let command: CommentsCommand + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const { id, uuid } = await server.videos.upload() + videoUUID = uuid + videoId = id + + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(server) + + userAccessTokenServer1 = await server.users.generateUserAndToken('user1') + await setDefaultChannelAvatar(server, 'user1_channel') + await setDefaultAccountAvatar(server, userAccessTokenServer1) + + command = server.comments + }) + + describe('User comments', function () { + + it('Should not have threads on this video', async function () { + const body = await command.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(0) + expect(body.totalNotDeletedComments).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + }) + + it('Should create a thread in this video', async function () { + const text = 'my super first comment' + + const comment = await command.createThread({ videoId: videoUUID, text }) + + expect(comment.inReplyToCommentId).to.be.null + expect(comment.text).equal('my super first comment') + expect(comment.videoId).to.equal(videoId) + expect(comment.id).to.equal(comment.threadId) + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(server.host) + expect(comment.account.url).to.equal(server.url + '/accounts/root') + expect(comment.totalReplies).to.equal(0) + expect(comment.totalRepliesFromVideoAuthor).to.equal(0) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + }) + + it('Should list threads of this video', async function () { + const body = await command.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(1) + expect(body.totalNotDeletedComments).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + + const comment = body.data[0] + expect(comment.inReplyToCommentId).to.be.null + expect(comment.text).equal('my super first comment') + expect(comment.videoId).to.equal(videoId) + expect(comment.id).to.equal(comment.threadId) + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(server.host) + + for (const avatar of comment.account.avatars) { + await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') + } + + expect(comment.totalReplies).to.equal(0) + expect(comment.totalRepliesFromVideoAuthor).to.equal(0) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + + threadId = comment.threadId + }) + + it('Should get all the thread created', async function () { + const body = await command.getThread({ videoId: videoUUID, threadId }) + + const rootComment = body.comment + expect(rootComment.inReplyToCommentId).to.be.null + expect(rootComment.text).equal('my super first comment') + expect(rootComment.videoId).to.equal(videoId) + expect(dateIsValid(rootComment.createdAt as string)).to.be.true + expect(dateIsValid(rootComment.updatedAt as string)).to.be.true + }) + + it('Should create multiple replies in this thread', async function () { + const text1 = 'my super answer to thread 1' + const created = await command.addReply({ videoId, toCommentId: threadId, text: text1 }) + const childCommentId = created.id + + const text2 = 'my super answer to answer of thread 1' + await command.addReply({ videoId, toCommentId: childCommentId, text: text2 }) + + const text3 = 'my second answer to thread 1' + await command.addReply({ videoId, toCommentId: threadId, text: text3 }) + }) + + it('Should get correctly the replies', async function () { + const tree = await command.getThread({ videoId: videoUUID, threadId }) + + expect(tree.comment.text).equal('my super first comment') + expect(tree.children).to.have.lengthOf(2) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.children).to.have.lengthOf(1) + + const childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') + expect(childOfFirstChild.children).to.have.lengthOf(0) + + const secondChild = tree.children[1] + expect(secondChild.comment.text).to.equal('my second answer to thread 1') + expect(secondChild.children).to.have.lengthOf(0) + + replyToDeleteId = secondChild.comment.id + }) + + it('Should create other threads', async function () { + const text1 = 'super thread 2' + await command.createThread({ videoId: videoUUID, text: text1 }) + + const text2 = 'super thread 3' + await command.createThread({ videoId: videoUUID, text: text2 }) + }) + + it('Should list the threads', async function () { + const body = await command.listThreads({ videoId: videoUUID, sort: 'createdAt' }) + + expect(body.total).to.equal(3) + expect(body.totalNotDeletedComments).to.equal(6) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(3) + + expect(body.data[0].text).to.equal('my super first comment') + expect(body.data[0].totalReplies).to.equal(3) + expect(body.data[1].text).to.equal('super thread 2') + expect(body.data[1].totalReplies).to.equal(0) + expect(body.data[2].text).to.equal('super thread 3') + expect(body.data[2].totalReplies).to.equal(0) + }) + + it('Should list the and sort them by total replies', async function () { + const body = await command.listThreads({ videoId: videoUUID, sort: 'totalReplies' }) + + expect(body.data[2].text).to.equal('my super first comment') + expect(body.data[2].totalReplies).to.equal(3) + }) + + it('Should delete a reply', async function () { + await command.delete({ videoId, commentId: replyToDeleteId }) + + { + const body = await command.listThreads({ videoId: videoUUID, sort: 'createdAt' }) + + expect(body.total).to.equal(3) + expect(body.totalNotDeletedComments).to.equal(5) + } + + { + const tree = await command.getThread({ videoId: videoUUID, threadId }) + + expect(tree.comment.text).equal('my super first comment') + expect(tree.children).to.have.lengthOf(2) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.children).to.have.lengthOf(1) + + const childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') + expect(childOfFirstChild.children).to.have.lengthOf(0) + + const deletedChildOfFirstChild = tree.children[1] + expect(deletedChildOfFirstChild.comment.text).to.equal('') + expect(deletedChildOfFirstChild.comment.isDeleted).to.be.true + expect(deletedChildOfFirstChild.comment.deletedAt).to.not.be.null + expect(deletedChildOfFirstChild.comment.account).to.be.null + expect(deletedChildOfFirstChild.children).to.have.lengthOf(0) + } + }) + + it('Should delete a complete thread', async function () { + await command.delete({ videoId, commentId: threadId }) + + const body = await command.listThreads({ videoId: videoUUID, sort: 'createdAt' }) + expect(body.total).to.equal(3) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(3) + + expect(body.data[0].text).to.equal('') + expect(body.data[0].isDeleted).to.be.true + expect(body.data[0].deletedAt).to.not.be.null + expect(body.data[0].account).to.be.null + expect(body.data[0].totalReplies).to.equal(2) + expect(body.data[1].text).to.equal('super thread 2') + expect(body.data[1].totalReplies).to.equal(0) + expect(body.data[2].text).to.equal('super thread 3') + expect(body.data[2].totalReplies).to.equal(0) + }) + + it('Should count replies from the video author correctly', async function () { + await command.createThread({ videoId: videoUUID, text: 'my super first comment' }) + + const { data } = await command.listThreads({ videoId: videoUUID }) + const threadId2 = data[0].threadId + + const text2 = 'a first answer to thread 4 by a third party' + await command.addReply({ token: userAccessTokenServer1, videoId, toCommentId: threadId2, text: text2 }) + + const text3 = 'my second answer to thread 4' + await command.addReply({ videoId, toCommentId: threadId2, text: text3 }) + + const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 }) + expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1) + expect(tree.comment.totalReplies).to.equal(2) + }) + }) + + describe('All instance comments', function () { + + it('Should list instance comments as admin', async function () { + { + const { data, total } = await command.listForAdmin({ start: 0, count: 1 }) + + expect(total).to.equal(7) + expect(data).to.have.lengthOf(1) + expect(data[0].text).to.equal('my second answer to thread 4') + expect(data[0].account.name).to.equal('root') + expect(data[0].account.displayName).to.equal('root') + expect(data[0].account.avatars).to.have.lengthOf(2) + } + + { + const { data, total } = await command.listForAdmin({ start: 1, count: 2 }) + + expect(total).to.equal(7) + expect(data).to.have.lengthOf(2) + + expect(data[0].account.avatars).to.have.lengthOf(2) + expect(data[1].account.avatars).to.have.lengthOf(2) + } + }) + + it('Should filter instance comments by isLocal', async function () { + const { total, data } = await command.listForAdmin({ isLocal: false }) + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + }) + + it('Should filter instance comments by onLocalVideo', async function () { + { + const { total, data } = await command.listForAdmin({ onLocalVideo: false }) + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + } + + { + const { total, data } = await command.listForAdmin({ onLocalVideo: true }) + + expect(data).to.not.have.lengthOf(0) + expect(total).to.not.equal(0) + } + }) + + it('Should search instance comments by account', async function () { + const { total, data } = await command.listForAdmin({ searchAccount: 'user' }) + + expect(data).to.have.lengthOf(1) + expect(total).to.equal(1) + + expect(data[0].text).to.equal('a first answer to thread 4 by a third party') + }) + + it('Should search instance comments by video', async function () { + { + const { total, data } = await command.listForAdmin({ searchVideo: 'video' }) + + expect(data).to.have.lengthOf(7) + expect(total).to.equal(7) + } + + { + const { total, data } = await command.listForAdmin({ searchVideo: 'hello' }) + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + } + }) + + it('Should search instance comments', async function () { + const { total, data } = await command.listForAdmin({ search: 'super thread 3' }) + + expect(total).to.equal(1) + + expect(data).to.have.lengthOf(1) + expect(data[0].text).to.equal('super thread 3') + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-description.ts b/packages/tests/src/api/videos/video-description.ts new file mode 100644 index 000000000..eb41cd71c --- /dev/null +++ b/packages/tests/src/api/videos/video-description.ts @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video description', function () { + let servers: PeerTubeServer[] = [] + let videoUUID = '' + let videoId: number + + const longDescription = 'my super description for server 1'.repeat(50) + + // 30 characters * 6 -> 240 characters + const truncatedDescription = 'my super description for server 1'.repeat(7) + 'my super descrip...' + + before(async function () { + this.timeout(40000) + + // Run servers + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + it('Should upload video with long description', async function () { + this.timeout(30000) + + const attributes = { + description: longDescription + } + await servers[0].videos.upload({ attributes }) + + await waitJobs(servers) + + const { data } = await servers[0].videos.list() + + videoId = data[0].id + videoUUID = data[0].uuid + }) + + it('Should have a truncated description on each server when listing videos', async function () { + for (const server of servers) { + const { data } = await server.videos.list() + const video = data.find(v => v.uuid === videoUUID) + + expect(video.description).to.equal(truncatedDescription) + expect(video.truncatedDescription).to.equal(truncatedDescription) + } + }) + + it('Should not have a truncated description on each server when getting videos', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.description).to.equal(longDescription) + expect(video.truncatedDescription).to.equal(truncatedDescription) + } + }) + + it('Should fetch long description on each server', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + const { description } = await server.videos.getDescription({ descriptionPath: video.descriptionPath }) + expect(description).to.equal(longDescription) + } + }) + + it('Should update with a short description', async function () { + const attributes = { + description: 'short description' + } + await servers[0].videos.update({ id: videoId, attributes }) + + await waitJobs(servers) + }) + + it('Should have a small description on each server', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.description).to.equal('short description') + + const { description } = await server.videos.getDescription({ descriptionPath: video.descriptionPath }) + expect(description).to.equal('short description') + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-files.ts b/packages/tests/src/api/videos/video-files.ts new file mode 100644 index 000000000..1d7c218a4 --- /dev/null +++ b/packages/tests/src/api/videos/video-files.ts @@ -0,0 +1,202 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeRawRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test videos files', function () { + let servers: PeerTubeServer[] + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(150_000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + }) + + describe('When deleting all files', function () { + let validId1: string + let validId2: string + + before(async function () { + this.timeout(360_000) + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 1' }) + validId1 = uuid + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 2' }) + validId2 = uuid + } + + await waitJobs(servers) + }) + + it('Should delete web video files', async function () { + this.timeout(30_000) + + await servers[0].videos.removeAllWebVideoFiles({ videoId: validId1 }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: validId1 }) + + expect(video.files).to.have.lengthOf(0) + expect(video.streamingPlaylists).to.have.lengthOf(1) + } + }) + + it('Should delete HLS files', async function () { + this.timeout(30_000) + + await servers[0].videos.removeHLSPlaylist({ videoId: validId2 }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: validId2 }) + + expect(video.files).to.have.length.above(0) + expect(video.streamingPlaylists).to.have.lengthOf(0) + } + }) + }) + + describe('When deleting a specific file', function () { + let webVideoId: string + let hlsId: string + + before(async function () { + this.timeout(120_000) + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' }) + webVideoId = uuid + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' }) + hlsId = uuid + } + + await waitJobs(servers) + }) + + it('Shoulde delete a web video file', async function () { + this.timeout(30_000) + + const video = await servers[0].videos.get({ id: webVideoId }) + const files = video.files + + await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: files[0].id }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: webVideoId }) + + expect(video.files).to.have.lengthOf(files.length - 1) + expect(video.files.find(f => f.id === files[0].id)).to.not.exist + } + }) + + it('Should delete all web video files', async function () { + this.timeout(30_000) + + const video = await servers[0].videos.get({ id: webVideoId }) + const files = video.files + + for (const file of files) { + await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: file.id }) + } + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: webVideoId }) + + expect(video.files).to.have.lengthOf(0) + } + }) + + it('Should delete a hls file', async function () { + this.timeout(30_000) + + const video = await servers[0].videos.get({ id: hlsId }) + const files = video.streamingPlaylists[0].files + const toDelete = files[0] + + await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: toDelete.id }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: hlsId }) + + expect(video.streamingPlaylists[0].files).to.have.lengthOf(files.length - 1) + expect(video.streamingPlaylists[0].files.find(f => f.id === toDelete.id)).to.not.exist + + const { text } = await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + + expect(text.includes(`-${toDelete.resolution.id}.m3u8`)).to.be.false + expect(text.includes(`-${video.streamingPlaylists[0].files[0].resolution.id}.m3u8`)).to.be.true + } + }) + + it('Should delete all hls files', async function () { + this.timeout(30_000) + + const video = await servers[0].videos.get({ id: hlsId }) + const files = video.streamingPlaylists[0].files + + for (const file of files) { + await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: file.id }) + } + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: hlsId }) + + expect(video.streamingPlaylists).to.have.lengthOf(0) + } + }) + + it('Should not delete last file of a video', async function () { + this.timeout(60_000) + + const webVideoOnly = await servers[0].videos.get({ id: hlsId }) + const hlsOnly = await servers[0].videos.get({ id: webVideoId }) + + for (let i = 0; i < 4; i++) { + await servers[0].videos.removeWebVideoFile({ videoId: webVideoOnly.id, fileId: webVideoOnly.files[i].id }) + await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[i].id }) + } + + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + await servers[0].videos.removeWebVideoFile({ videoId: webVideoOnly.id, fileId: webVideoOnly.files[4].id, expectedStatus }) + await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[4].id, expectedStatus }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-imports.ts b/packages/tests/src/api/videos/video-imports.ts new file mode 100644 index 000000000..09efe9931 --- /dev/null +++ b/packages/tests/src/api/videos/video-imports.ts @@ -0,0 +1,634 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists, remove } from 'fs-extra/esm' +import { readdir } from 'fs/promises' +import { join } from 'path' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { CustomConfig, HttpStatusCode, Video, VideoImportState, VideoPrivacy, VideoResolution, VideoState } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + createSingleServer, + doubleFollow, + getServerImportConfig, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { DeepPartial } from '@peertube/peertube-typescript-utils' +import { testCaptionFile } from '@tests/shared/captions.js' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' + +async function checkVideosServer1 (server: PeerTubeServer, idHttp: string, idMagnet: string, idTorrent: string) { + const videoHttp = await server.videos.get({ id: idHttp }) + + expect(videoHttp.name).to.equal('small video - youtube') + expect(videoHttp.category.label).to.equal('News & Politics') + expect(videoHttp.licence.label).to.equal('Attribution') + expect(videoHttp.language.label).to.equal('Unknown') + expect(videoHttp.nsfw).to.be.false + expect(videoHttp.description).to.equal('this is a super description') + expect(videoHttp.tags).to.deep.equal([ 'tag1', 'tag2' ]) + expect(videoHttp.files).to.have.lengthOf(1) + + const originallyPublishedAt = new Date(videoHttp.originallyPublishedAt) + expect(originallyPublishedAt.getDate()).to.equal(14) + expect(originallyPublishedAt.getMonth()).to.equal(0) + expect(originallyPublishedAt.getFullYear()).to.equal(2019) + + const videoMagnet = await server.videos.get({ id: idMagnet }) + const videoTorrent = await server.videos.get({ id: idTorrent }) + + for (const video of [ videoMagnet, videoTorrent ]) { + expect(video.category.label).to.equal('Unknown') + expect(video.licence.label).to.equal('Unknown') + expect(video.language.label).to.equal('Unknown') + expect(video.nsfw).to.be.false + expect(video.description).to.equal('this is a super torrent description') + expect(video.tags).to.deep.equal([ 'tag_torrent1', 'tag_torrent2' ]) + expect(video.files).to.have.lengthOf(1) + } + + expect(videoTorrent.name).to.contain('你好 世界 720p.mp4') + expect(videoMagnet.name).to.contain('super peertube2 video') + + const bodyCaptions = await server.captions.list({ videoId: idHttp }) + expect(bodyCaptions.total).to.equal(2) +} + +async function checkVideoServer2 (server: PeerTubeServer, id: number | string) { + const video = await server.videos.get({ id }) + + expect(video.name).to.equal('my super name') + expect(video.category.label).to.equal('Entertainment') + expect(video.licence.label).to.equal('Public Domain Dedication') + expect(video.language.label).to.equal('English') + expect(video.nsfw).to.be.false + expect(video.description).to.equal('my super description') + expect(video.tags).to.deep.equal([ 'supertag1', 'supertag2' ]) + + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', video.thumbnailPath) + + expect(video.files).to.have.lengthOf(1) + + const bodyCaptions = await server.captions.list({ videoId: id }) + expect(bodyCaptions.total).to.equal(2) +} + +describe('Test video imports', function () { + + if (areHttpImportTestsDisabled()) return + + function runSuite (mode: 'youtube-dl' | 'yt-dlp') { + + describe('Import ' + mode, function () { + let servers: PeerTubeServer[] = [] + + before(async function () { + this.timeout(60_000) + + servers = await createMultipleServers(2, getServerImportConfig(mode)) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + for (const server of servers) { + await server.config.updateExistingSubConfig({ + newConfig: { + transcoding: { + alwaysTranscodeOriginalResolution: false + } + } + }) + } + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should import videos on server 1', async function () { + this.timeout(60_000) + + const baseAttributes = { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + + { + const attributes = { ...baseAttributes, targetUrl: FIXTURE_URLS.youtube } + const { video } = await servers[0].imports.importVideo({ attributes }) + expect(video.name).to.equal('small video - youtube') + + { + expect(video.thumbnailPath).to.match(new RegExp(`^/lazy-static/thumbnails/.+.jpg$`)) + expect(video.previewPath).to.match(new RegExp(`^/lazy-static/previews/.+.jpg$`)) + + const suffix = mode === 'yt-dlp' + ? '_yt_dlp' + : '' + + await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_thumbnail' + suffix, video.thumbnailPath) + await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_preview' + suffix, video.previewPath) + } + + const bodyCaptions = await servers[0].captions.list({ videoId: video.id }) + const videoCaptions = bodyCaptions.data + expect(videoCaptions).to.have.lengthOf(2) + + { + const enCaption = videoCaptions.find(caption => caption.language.id === 'en') + expect(enCaption).to.exist + expect(enCaption.language.label).to.equal('English') + expect(enCaption.captionPath).to.match(new RegExp(`^/lazy-static/video-captions/.+-en.vtt$`)) + + const regex = `WEBVTT[ \n]+Kind: captions[ \n]+` + + `(Language: en[ \n]+)?` + + `00:00:01.600 --> 00:00:04.200( position:\\d+% line:\\d+%)?[ \n]+English \\(US\\)[ \n]+` + + `00:00:05.900 --> 00:00:07.999( position:\\d+% line:\\d+%)?[ \n]+This is a subtitle in American English[ \n]+` + + `00:00:10.000 --> 00:00:14.000( position:\\d+% line:\\d+%)?[ \n]+Adding subtitles is very easy to do` + await testCaptionFile(servers[0].url, enCaption.captionPath, new RegExp(regex)) + } + + { + const frCaption = videoCaptions.find(caption => caption.language.id === 'fr') + expect(frCaption).to.exist + expect(frCaption.language.label).to.equal('French') + expect(frCaption.captionPath).to.match(new RegExp(`^/lazy-static/video-captions/.+-fr.vtt`)) + + const regex = `WEBVTT[ \n]+Kind: captions[ \n]+` + + `(Language: fr[ \n]+)?` + + `00:00:01.600 --> 00:00:04.200( position:\\d+% line:\\d+%)?[ \n]+Français \\(FR\\)[ \n]+` + + `00:00:05.900 --> 00:00:07.999( position:\\d+% line:\\d+%)?[ \n]+C'est un sous-titre français[ \n]+` + + `00:00:10.000 --> 00:00:14.000( position:\\d+% line:\\d+%)?[ \n]+Ajouter un sous-titre est vraiment facile` + + await testCaptionFile(servers[0].url, frCaption.captionPath, new RegExp(regex)) + } + } + + { + const attributes = { + ...baseAttributes, + magnetUri: FIXTURE_URLS.magnet, + description: 'this is a super torrent description', + tags: [ 'tag_torrent1', 'tag_torrent2' ] + } + const { video } = await servers[0].imports.importVideo({ attributes }) + expect(video.name).to.equal('super peertube2 video') + } + + { + const attributes = { + ...baseAttributes, + torrentfile: 'video-720p.torrent' as any, + description: 'this is a super torrent description', + tags: [ 'tag_torrent1', 'tag_torrent2' ] + } + const { video } = await servers[0].imports.importVideo({ attributes }) + expect(video.name).to.equal('你好 世界 720p.mp4') + } + }) + + it('Should list the videos to import in my videos on server 1', async function () { + const { total, data } = await servers[0].videos.listMyVideos({ sort: 'createdAt' }) + + expect(total).to.equal(3) + + expect(data).to.have.lengthOf(3) + expect(data[0].name).to.equal('small video - youtube') + expect(data[1].name).to.equal('super peertube2 video') + expect(data[2].name).to.equal('你好 世界 720p.mp4') + }) + + it('Should list the videos to import in my imports on server 1', async function () { + const { total, data: videoImports } = await servers[0].imports.getMyVideoImports({ sort: '-createdAt' }) + expect(total).to.equal(3) + + expect(videoImports).to.have.lengthOf(3) + + expect(videoImports[2].targetUrl).to.equal(FIXTURE_URLS.youtube) + expect(videoImports[2].magnetUri).to.be.null + expect(videoImports[2].torrentName).to.be.null + expect(videoImports[2].video.name).to.equal('small video - youtube') + + expect(videoImports[1].targetUrl).to.be.null + expect(videoImports[1].magnetUri).to.equal(FIXTURE_URLS.magnet) + expect(videoImports[1].torrentName).to.be.null + expect(videoImports[1].video.name).to.equal('super peertube2 video') + + expect(videoImports[0].targetUrl).to.be.null + expect(videoImports[0].magnetUri).to.be.null + expect(videoImports[0].torrentName).to.equal('video-720p.torrent') + expect(videoImports[0].video.name).to.equal('你好 世界 720p.mp4') + }) + + it('Should filter my imports on target URL', async function () { + const { total, data: videoImports } = await servers[0].imports.getMyVideoImports({ targetUrl: FIXTURE_URLS.youtube }) + expect(total).to.equal(1) + expect(videoImports).to.have.lengthOf(1) + + expect(videoImports[0].targetUrl).to.equal(FIXTURE_URLS.youtube) + }) + + it('Should search in my imports', async function () { + const { total, data: videoImports } = await servers[0].imports.getMyVideoImports({ search: 'peertube2' }) + expect(total).to.equal(1) + expect(videoImports).to.have.lengthOf(1) + + expect(videoImports[0].magnetUri).to.equal(FIXTURE_URLS.magnet) + expect(videoImports[0].video.name).to.equal('super peertube2 video') + }) + + it('Should have the video listed on the two instances', async function () { + this.timeout(120_000) + + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + + const [ videoHttp, videoMagnet, videoTorrent ] = data + await checkVideosServer1(server, videoHttp.uuid, videoMagnet.uuid, videoTorrent.uuid) + } + }) + + it('Should import a video on server 2 with some fields', async function () { + this.timeout(60_000) + + const { video } = await servers[1].imports.importVideo({ + attributes: { + targetUrl: FIXTURE_URLS.youtube, + channelId: servers[1].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + category: 10, + licence: 7, + language: 'en', + name: 'my super name', + description: 'my super description', + tags: [ 'supertag1', 'supertag2' ], + thumbnailfile: 'custom-thumbnail.jpg' + } + }) + expect(video.name).to.equal('my super name') + }) + + it('Should have the videos listed on the two instances', async function () { + this.timeout(120_000) + + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + expect(total).to.equal(4) + expect(data).to.have.lengthOf(4) + + await checkVideoServer2(server, data[0].uuid) + + const [ , videoHttp, videoMagnet, videoTorrent ] = data + await checkVideosServer1(server, videoHttp.uuid, videoMagnet.uuid, videoTorrent.uuid) + } + }) + + it('Should import a video that will be transcoded', async function () { + this.timeout(240_000) + + const attributes = { + name: 'transcoded video', + magnetUri: FIXTURE_URLS.magnet, + channelId: servers[1].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video } = await servers[1].imports.importVideo({ attributes }) + const videoUUID = video.uuid + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.name).to.equal('transcoded video') + expect(video.files).to.have.lengthOf(4) + } + }) + + it('Should import no HDR version on a HDR video', async function () { + this.timeout(300_000) + + const config: DeepPartial = { + transcoding: { + enabled: true, + resolutions: { + '0p': false, + '144p': true, + '240p': true, + '360p': false, + '480p': false, + '720p': false, + '1080p': false, // the resulting resolution shouldn't be higher than this, and not vp9.2/av01 + '1440p': false, + '2160p': false + }, + webVideos: { enabled: true }, + hls: { enabled: false } + } + } + await servers[0].config.updateExistingSubConfig({ newConfig: config }) + + const attributes = { + name: 'hdr video', + targetUrl: FIXTURE_URLS.youtubeHDR, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video: videoImported } = await servers[0].imports.importVideo({ attributes }) + const videoUUID = videoImported.uuid + + await waitJobs(servers) + + // test resolution + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.name).to.equal('hdr video') + const maxResolution = Math.max.apply(Math, video.files.map(function (o) { return o.resolution.id })) + expect(maxResolution, 'expected max resolution not met').to.equals(VideoResolution.H_240P) + }) + + it('Should not import resolution higher than enabled transcoding resolution', async function () { + this.timeout(300_000) + + const config: DeepPartial = { + transcoding: { + enabled: true, + resolutions: { + '0p': false, + '144p': true, + '240p': false, + '360p': false, + '480p': false, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + alwaysTranscodeOriginalResolution: false + } + } + await servers[0].config.updateExistingSubConfig({ newConfig: config }) + + const attributes = { + name: 'small resolution video', + targetUrl: FIXTURE_URLS.youtube, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video: videoImported } = await servers[0].imports.importVideo({ attributes }) + const videoUUID = videoImported.uuid + + await waitJobs(servers) + + // test resolution + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.name).to.equal('small resolution video') + expect(video.files).to.have.lengthOf(1) + expect(video.files[0].resolution.id).to.equal(144) + }) + + it('Should import resolution higher than enabled transcoding resolution', async function () { + this.timeout(300_000) + + const config: DeepPartial = { + transcoding: { + alwaysTranscodeOriginalResolution: true + } + } + await servers[0].config.updateExistingSubConfig({ newConfig: config }) + + const attributes = { + name: 'bigger resolution video', + targetUrl: FIXTURE_URLS.youtube, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video: videoImported } = await servers[0].imports.importVideo({ attributes }) + const videoUUID = videoImported.uuid + + await waitJobs(servers) + + // test resolution + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.name).to.equal('bigger resolution video') + + expect(video.files).to.have.lengthOf(2) + expect(video.files.find(f => f.resolution.id === 240)).to.exist + expect(video.files.find(f => f.resolution.id === 144)).to.exist + }) + + it('Should import a peertube video', async function () { + this.timeout(120_000) + + const toTest = [ FIXTURE_URLS.peertube_long ] + + // TODO: include peertube_short when https://github.com/ytdl-org/youtube-dl/pull/29475 is merged + if (mode === 'yt-dlp') { + toTest.push(FIXTURE_URLS.peertube_short) + } + + for (const targetUrl of toTest) { + await servers[0].config.disableTranscoding() + + const attributes = { + targetUrl, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video } = await servers[0].imports.importVideo({ attributes }) + const videoUUID = video.uuid + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.name).to.equal('E2E tests') + + const { data: captions } = await server.captions.list({ videoId: videoUUID }) + expect(captions).to.have.lengthOf(1) + expect(captions[0].language.id).to.equal('fr') + + const str = `WEBVTT FILE\r?\n\r?\n` + + `1\r?\n` + + `00:00:04.000 --> 00:00:09.000\r?\n` + + `January 1, 1994. The North American` + await testCaptionFile(server.url, captions[0].captionPath, new RegExp(str)) + } + } + }) + + after(async function () { + await cleanupTests(servers) + }) + }) + } + + // FIXME: youtube-dl seems broken + // runSuite('youtube-dl') + + runSuite('yt-dlp') + + describe('Delete/cancel an import', function () { + let server: PeerTubeServer + + let finishedImportId: number + let finishedVideo: Video + let pendingImportId: number + + async function importVideo (name: string) { + const attributes = { name, channelId: server.store.channel.id, targetUrl: FIXTURE_URLS.goodVideo } + const res = await server.imports.importVideo({ attributes }) + + return res.id + } + + before(async function () { + this.timeout(120_000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + finishedImportId = await importVideo('finished') + await waitJobs([ server ]) + + await server.jobs.pauseJobQueue() + pendingImportId = await importVideo('pending') + + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(2) + + finishedVideo = data.find(i => i.id === finishedImportId).video + }) + + it('Should delete a video import', async function () { + await server.imports.delete({ importId: finishedImportId }) + + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.equal(pendingImportId) + expect(data[0].state.id).to.equal(VideoImportState.PENDING) + }) + + it('Should not have deleted the associated video', async function () { + const video = await server.videos.get({ id: finishedVideo.id, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + expect(video.name).to.equal('finished') + expect(video.state.id).to.equal(VideoState.PUBLISHED) + }) + + it('Should cancel a video import', async function () { + await server.imports.cancel({ importId: pendingImportId }) + + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.equal(pendingImportId) + expect(data[0].state.id).to.equal(VideoImportState.CANCELLED) + }) + + it('Should not have processed the cancelled video import', async function () { + this.timeout(60_000) + + await server.jobs.resumeJobQueue() + + await waitJobs([ server ]) + + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.equal(pendingImportId) + expect(data[0].state.id).to.equal(VideoImportState.CANCELLED) + expect(data[0].video.state.id).to.equal(VideoState.TO_IMPORT) + }) + + it('Should delete the cancelled video import', async function () { + await server.imports.delete({ importId: pendingImportId }) + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(0) + }) + + after(async function () { + await cleanupTests([ server ]) + }) + }) + + describe('Auto update', function () { + let server: PeerTubeServer + + function quickPeerTubeImport () { + const attributes = { + targetUrl: FIXTURE_URLS.peertube_long, + channelId: server.store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + + return server.imports.importVideo({ attributes }) + } + + async function testBinaryUpdate (releaseUrl: string, releaseName: string) { + await remove(join(server.servers.buildDirectory('bin'), releaseName)) + + await server.kill() + await server.run({ + import: { + videos: { + http: { + youtube_dl_release: { + url: releaseUrl, + name: releaseName + } + } + } + } + }) + + await quickPeerTubeImport() + + const base = server.servers.buildDirectory('bin') + const content = await readdir(base) + const binaryPath = join(base, releaseName) + + expect(await pathExists(binaryPath), `${binaryPath} does not exist in ${base} (${content.join(', ')})`).to.be.true + } + + before(async function () { + this.timeout(30_000) + + // Run servers + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + }) + + it('Should update youtube-dl from github URL', async function () { + this.timeout(120_000) + + await testBinaryUpdate('https://api.github.com/repos/ytdl-org/youtube-dl/releases', 'youtube-dl') + }) + + it('Should update youtube-dl from raw URL', async function () { + this.timeout(120_000) + + await testBinaryUpdate('https://yt-dl.org/downloads/latest/youtube-dl', 'youtube-dl') + }) + + it('Should update youtube-dl from youtube-dl fork', async function () { + this.timeout(120_000) + + await testBinaryUpdate('https://api.github.com/repos/yt-dlp/yt-dlp/releases', 'yt-dlp') + }) + + after(async function () { + await cleanupTests([ server ]) + }) + }) +}) diff --git a/packages/tests/src/api/videos/video-nsfw.ts b/packages/tests/src/api/videos/video-nsfw.ts new file mode 100644 index 000000000..fc5225dd2 --- /dev/null +++ b/packages/tests/src/api/videos/video-nsfw.ts @@ -0,0 +1,227 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' +import { BooleanBothQuery, CustomConfig, ResultList, Video, VideosOverview } from '@peertube/peertube-models' + +function createOverviewRes (overview: VideosOverview) { + const videos = overview.categories[0].videos + return { data: videos, total: videos.length } +} + +describe('Test video NSFW policy', function () { + let server: PeerTubeServer + let userAccessToken: string + let customConfig: CustomConfig + + async function getVideosFunctions (token?: string, query: { nsfw?: BooleanBothQuery } = {}) { + const user = await server.users.getMyInfo() + + const channelName = user.videoChannels[0].name + const accountName = user.account.name + '@' + user.account.host + + const hasQuery = Object.keys(query).length !== 0 + let promises: Promise>[] + + if (token) { + promises = [ + server.search.advancedVideoSearch({ token, search: { search: 'n', sort: '-publishedAt', ...query } }), + server.videos.listWithToken({ token, ...query }), + server.videos.listByAccount({ token, handle: accountName, ...query }), + server.videos.listByChannel({ token, handle: channelName, ...query }) + ] + + // Overviews do not support video filters + if (!hasQuery) { + const p = server.overviews.getVideos({ page: 1, token }) + .then(res => createOverviewRes(res)) + promises.push(p) + } + + return Promise.all(promises) + } + + promises = [ + server.search.searchVideos({ search: 'n', sort: '-publishedAt' }), + server.videos.list(), + server.videos.listByAccount({ token: null, handle: accountName }), + server.videos.listByChannel({ token: null, handle: channelName }) + ] + + // Overviews do not support video filters + if (!hasQuery) { + const p = server.overviews.getVideos({ page: 1 }) + .then(res => createOverviewRes(res)) + promises.push(p) + } + + return Promise.all(promises) + } + + before(async function () { + this.timeout(50000) + server = await createSingleServer(1) + + // Get the access tokens + await setAccessTokensToServers([ server ]) + + { + const attributes = { name: 'nsfw', nsfw: true, category: 1 } + await server.videos.upload({ attributes }) + } + + { + const attributes = { name: 'normal', nsfw: false, category: 1 } + await server.videos.upload({ attributes }) + } + + customConfig = await server.config.getCustomConfig() + }) + + describe('Instance default NSFW policy', function () { + + it('Should display NSFW videos with display default NSFW policy', async function () { + const serverConfig = await server.config.getConfig() + expect(serverConfig.instance.defaultNSFWPolicy).to.equal('display') + + for (const body of await getVideosFunctions()) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + + it('Should not display NSFW videos with do_not_list default NSFW policy', async function () { + customConfig.instance.defaultNSFWPolicy = 'do_not_list' + await server.config.updateCustomConfig({ newCustomConfig: customConfig }) + + const serverConfig = await server.config.getConfig() + expect(serverConfig.instance.defaultNSFWPolicy).to.equal('do_not_list') + + for (const body of await getVideosFunctions()) { + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('normal') + } + }) + + it('Should display NSFW videos with blur default NSFW policy', async function () { + customConfig.instance.defaultNSFWPolicy = 'blur' + await server.config.updateCustomConfig({ newCustomConfig: customConfig }) + + const serverConfig = await server.config.getConfig() + expect(serverConfig.instance.defaultNSFWPolicy).to.equal('blur') + + for (const body of await getVideosFunctions()) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + }) + + describe('User NSFW policy', function () { + + it('Should create a user having the default nsfw policy', async function () { + const username = 'user1' + const password = 'my super password' + await server.users.create({ username, password }) + + userAccessToken = await server.login.getAccessToken({ username, password }) + + const user = await server.users.getMyInfo({ token: userAccessToken }) + expect(user.nsfwPolicy).to.equal('blur') + }) + + it('Should display NSFW videos with blur user NSFW policy', async function () { + customConfig.instance.defaultNSFWPolicy = 'do_not_list' + await server.config.updateCustomConfig({ newCustomConfig: customConfig }) + + for (const body of await getVideosFunctions(userAccessToken)) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + + it('Should display NSFW videos with display user NSFW policy', async function () { + await server.users.updateMe({ nsfwPolicy: 'display' }) + + for (const body of await getVideosFunctions(server.accessToken)) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + + it('Should not display NSFW videos with do_not_list user NSFW policy', async function () { + await server.users.updateMe({ nsfwPolicy: 'do_not_list' }) + + for (const body of await getVideosFunctions(server.accessToken)) { + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('normal') + } + }) + + it('Should be able to see my NSFW videos even with do_not_list user NSFW policy', async function () { + const { total, data } = await server.videos.listMyVideos() + expect(total).to.equal(2) + + expect(data).to.have.lengthOf(2) + expect(data[0].name).to.equal('normal') + expect(data[1].name).to.equal('nsfw') + }) + + it('Should display NSFW videos when the nsfw param === true', async function () { + for (const body of await getVideosFunctions(server.accessToken, { nsfw: 'true' })) { + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('nsfw') + } + }) + + it('Should hide NSFW videos when the nsfw param === true', async function () { + for (const body of await getVideosFunctions(server.accessToken, { nsfw: 'false' })) { + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('normal') + } + }) + + it('Should display both videos when the nsfw param === both', async function () { + for (const body of await getVideosFunctions(server.accessToken, { nsfw: 'both' })) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-passwords.ts b/packages/tests/src/api/videos/video-passwords.ts new file mode 100644 index 000000000..60e0e28bd --- /dev/null +++ b/packages/tests/src/api/videos/video-passwords.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createSingleServer, + VideoPasswordsCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar +} from '@peertube/peertube-server-commands' +import { VideoPrivacy } from '@peertube/peertube-models' + +describe('Test video passwords', function () { + let server: PeerTubeServer + let videoUUID: string + + let userAccessTokenServer1: string + + let videoPasswords: string[] = [] + let command: VideoPasswordsCommand + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + for (let i = 0; i < 10; i++) { + videoPasswords.push(`password ${i + 1}`) + } + const { uuid } = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords } }) + videoUUID = uuid + + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(server) + + userAccessTokenServer1 = await server.users.generateUserAndToken('user1') + await setDefaultChannelAvatar(server, 'user1_channel') + await setDefaultAccountAvatar(server, userAccessTokenServer1) + + command = server.videoPasswords + }) + + it('Should list video passwords', async function () { + const body = await command.list({ videoId: videoUUID }) + + expect(body.total).to.equal(10) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(10) + }) + + it('Should filter passwords on this video', async function () { + const body = await command.list({ videoId: videoUUID, count: 2, start: 3, sort: 'createdAt' }) + + expect(body.total).to.equal(10) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].password).to.equal('password 4') + expect(body.data[1].password).to.equal('password 5') + }) + + it('Should update password for this video', async function () { + videoPasswords = [ 'my super new password 1', 'my super new password 2' ] + + await command.updateAll({ videoId: videoUUID, passwords: videoPasswords }) + const body = await command.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].password).to.equal('my super new password 2') + expect(body.data[1].password).to.equal('my super new password 1') + }) + + it('Should delete one password', async function () { + { + const body = await command.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + await command.remove({ id: body.data[0].id, videoId: videoUUID }) + } + { + const body = await command.list({ videoId: videoUUID }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-playlist-thumbnails.ts b/packages/tests/src/api/videos/video-playlist-thumbnails.ts new file mode 100644 index 000000000..d79c92f72 --- /dev/null +++ b/packages/tests/src/api/videos/video-playlist-thumbnails.ts @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' +import { VideoPlaylistPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Playlist thumbnail', function () { + let servers: PeerTubeServer[] = [] + + let playlistWithoutThumbnailId: number + let playlistWithThumbnailId: number + + let withThumbnailE1: number + let withThumbnailE2: number + let withoutThumbnailE1: number + let withoutThumbnailE2: number + + let video1: number + let video2: number + + async function getPlaylistWithoutThumbnail (server: PeerTubeServer) { + const body = await server.playlists.list({ start: 0, count: 10 }) + + return body.data.find(p => p.displayName === 'playlist without thumbnail') + } + + async function getPlaylistWithThumbnail (server: PeerTubeServer) { + const body = await server.playlists.list({ start: 0, count: 10 }) + + return body.data.find(p => p.displayName === 'playlist with thumbnail') + } + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + for (const server of servers) { + await server.config.disableTranscoding() + } + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + video1 = (await servers[0].videos.quickUpload({ name: 'video 1' })).id + video2 = (await servers[0].videos.quickUpload({ name: 'video 2' })).id + + await waitJobs(servers) + }) + + it('Should automatically update the thumbnail when adding an element', async function () { + this.timeout(30000) + + const created = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist without thumbnail', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[1].store.channel.id + } + }) + playlistWithoutThumbnailId = created.id + + const added = await servers[1].playlists.addElement({ + playlistId: playlistWithoutThumbnailId, + attributes: { videoId: video1 } + }) + withoutThumbnailE1 = added.id + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithoutThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath) + } + }) + + it('Should not update the thumbnail if we explicitly uploaded a thumbnail', async function () { + this.timeout(30000) + + const created = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist with thumbnail', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[1].store.channel.id, + thumbnailfile: 'custom-thumbnail.jpg' + } + }) + playlistWithThumbnailId = created.id + + const added = await servers[1].playlists.addElement({ + playlistId: playlistWithThumbnailId, + attributes: { videoId: video1 } + }) + withThumbnailE1 = added.id + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + } + }) + + it('Should automatically update the thumbnail when moving the first element', async function () { + this.timeout(30000) + + const added = await servers[1].playlists.addElement({ + playlistId: playlistWithoutThumbnailId, + attributes: { videoId: video2 } + }) + withoutThumbnailE2 = added.id + + await servers[1].playlists.reorderElements({ + playlistId: playlistWithoutThumbnailId, + attributes: { + startPosition: 1, + insertAfterPosition: 2 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithoutThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath) + } + }) + + it('Should not update the thumbnail when moving the first element if we explicitly uploaded a thumbnail', async function () { + this.timeout(30000) + + const added = await servers[1].playlists.addElement({ + playlistId: playlistWithThumbnailId, + attributes: { videoId: video2 } + }) + withThumbnailE2 = added.id + + await servers[1].playlists.reorderElements({ + playlistId: playlistWithThumbnailId, + attributes: { + startPosition: 1, + insertAfterPosition: 2 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + } + }) + + it('Should automatically update the thumbnail when deleting the first element', async function () { + this.timeout(30000) + + await servers[1].playlists.removeElement({ + playlistId: playlistWithoutThumbnailId, + elementId: withoutThumbnailE1 + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithoutThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath) + } + }) + + it('Should not update the thumbnail when deleting the first element if we explicitly uploaded a thumbnail', async function () { + this.timeout(30000) + + await servers[1].playlists.removeElement({ + playlistId: playlistWithThumbnailId, + elementId: withThumbnailE1 + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + } + }) + + it('Should the thumbnail when we delete the last element', async function () { + this.timeout(30000) + + await servers[1].playlists.removeElement({ + playlistId: playlistWithoutThumbnailId, + elementId: withoutThumbnailE2 + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithoutThumbnail(server) + expect(p.thumbnailPath).to.be.null + } + }) + + it('Should not update the thumbnail when we delete the last element if we explicitly uploaded a thumbnail', async function () { + this.timeout(30000) + + await servers[1].playlists.removeElement({ + playlistId: playlistWithThumbnailId, + elementId: withThumbnailE2 + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-playlists.ts b/packages/tests/src/api/videos/video-playlists.ts new file mode 100644 index 000000000..578d01093 --- /dev/null +++ b/packages/tests/src/api/videos/video-playlists.ts @@ -0,0 +1,1210 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + VideoPlaylist, + VideoPlaylistCreateResult, + VideoPlaylistElementType, + VideoPlaylistElementType_Type, + VideoPlaylistPrivacy, + VideoPlaylistType, + VideoPrivacy +} from '@peertube/peertube-models' +import { uuidToShort } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + PlaylistsCommand, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' +import { checkPlaylistFilesWereRemoved } from '@tests/shared/video-playlists.js' + +async function checkPlaylistElementType ( + servers: PeerTubeServer[], + playlistId: string, + type: VideoPlaylistElementType_Type, + position: number, + name: string, + total: number +) { + for (const server of servers) { + const body = await server.playlists.listVideos({ token: server.accessToken, playlistId, start: 0, count: 10 }) + expect(body.total).to.equal(total) + + const videoElement = body.data.find(e => e.position === position) + expect(videoElement.type).to.equal(type, 'On server ' + server.url) + + if (type === VideoPlaylistElementType.REGULAR) { + expect(videoElement.video).to.not.be.null + expect(videoElement.video.name).to.equal(name) + } else { + expect(videoElement.video).to.be.null + } + } +} + +describe('Test video playlists', function () { + let servers: PeerTubeServer[] = [] + + let playlistServer2Id1: number + let playlistServer2Id2: number + let playlistServer2UUID2: string + + let playlistServer1Id: number + let playlistServer1DisplayName: string + let playlistServer1UUID: string + let playlistServer1UUID2: string + + let playlistElementServer1Video4: number + let playlistElementServer1Video5: number + let playlistElementNSFW: number + + let nsfwVideoServer1: number + + let userTokenServer1: string + + let commands: PlaylistsCommand[] + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + for (const server of servers) { + await server.config.disableTranscoding() + } + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + // Server 1 and server 3 follow each other + await doubleFollow(servers[0], servers[2]) + + commands = servers.map(s => s.playlists) + + { + servers[0].store.videos = [] + servers[1].store.videos = [] + servers[2].store.videos = [] + + for (const server of servers) { + for (let i = 0; i < 7; i++) { + const name = `video ${i} server ${server.serverNumber}` + const video = await server.videos.upload({ attributes: { name, nsfw: false } }) + + server.store.videos.push(video) + } + } + } + + nsfwVideoServer1 = (await servers[0].videos.quickUpload({ name: 'NSFW video', nsfw: true })).id + + userTokenServer1 = await servers[0].users.generateUserAndToken('user1') + + await waitJobs(servers) + }) + + describe('Check playlists filters and privacies', function () { + + it('Should list video playlist privacies', async function () { + const privacies = await commands[0].getPrivacies() + + expect(Object.keys(privacies)).to.have.length.at.least(3) + expect(privacies[3]).to.equal('Private') + }) + + it('Should filter on playlist type', async function () { + this.timeout(30000) + + const token = servers[0].accessToken + + await commands[0].create({ + attributes: { + displayName: 'my super playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + description: 'my super description', + thumbnailfile: 'custom-thumbnail.jpg', + videoChannelId: servers[0].store.channel.id + } + }) + + { + const body = await commands[0].listByAccount({ token, handle: 'root', playlistType: VideoPlaylistType.WATCH_LATER }) + + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlist = body.data[0] + expect(playlist.displayName).to.equal('Watch later') + expect(playlist.type.id).to.equal(VideoPlaylistType.WATCH_LATER) + expect(playlist.type.label).to.equal('Watch later') + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE) + } + + { + const bodyList = await commands[0].list({ playlistType: VideoPlaylistType.WATCH_LATER }) + const bodyChannel = await commands[0].listByChannel({ handle: 'root_channel', playlistType: VideoPlaylistType.WATCH_LATER }) + + for (const body of [ bodyList, bodyChannel ]) { + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + } + + { + const bodyList = await commands[0].list({ playlistType: VideoPlaylistType.REGULAR }) + const bodyChannel = await commands[0].listByChannel({ handle: 'root_channel', playlistType: VideoPlaylistType.REGULAR }) + + let playlist: VideoPlaylist = null + for (const body of [ bodyList, bodyChannel ]) { + + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + playlist = body.data[0] + expect(playlist.displayName).to.equal('my super playlist') + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC) + expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) + } + + await commands[0].update({ + playlistId: playlist.id, + attributes: { + privacy: VideoPlaylistPrivacy.PRIVATE + } + }) + } + + { + const bodyList = await commands[0].list({ playlistType: VideoPlaylistType.REGULAR }) + const bodyChannel = await commands[0].listByChannel({ handle: 'root_channel', playlistType: VideoPlaylistType.REGULAR }) + + for (const body of [ bodyList, bodyChannel ]) { + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + } + + { + const body = await commands[0].listByAccount({ handle: 'root' }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should get private playlist for a classic user', async function () { + const token = await servers[0].users.generateUserAndToken('toto') + + const body = await commands[0].listByAccount({ token, handle: 'toto' }) + + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlistId = body.data[0].id + await commands[0].listVideos({ token, playlistId }) + }) + }) + + describe('Create and federate playlists', function () { + + it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () { + this.timeout(30000) + + await commands[0].create({ + attributes: { + displayName: 'my super playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + description: 'my super description', + thumbnailfile: 'custom-thumbnail.jpg', + videoChannelId: servers[0].store.channel.id + } + }) + + await waitJobs(servers) + // Processing a playlist by the receiver could be long + await wait(3000) + + for (const server of servers) { + const body = await server.playlists.list({ start: 0, count: 5 }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlistFromList = body.data[0] + + const playlistFromGet = await server.playlists.get({ playlistId: playlistFromList.uuid }) + + for (const playlist of [ playlistFromGet, playlistFromList ]) { + expect(playlist.id).to.be.a('number') + expect(playlist.uuid).to.be.a('string') + + expect(playlist.isLocal).to.equal(server.serverNumber === 1) + + expect(playlist.displayName).to.equal('my super playlist') + expect(playlist.description).to.equal('my super description') + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC) + expect(playlist.privacy.label).to.equal('Public') + expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) + expect(playlist.type.label).to.equal('Regular') + expect(playlist.embedPath).to.equal('/video-playlists/embed/' + playlist.uuid) + + expect(playlist.videosLength).to.equal(0) + + expect(playlist.ownerAccount.name).to.equal('root') + expect(playlist.ownerAccount.displayName).to.equal('root') + expect(playlist.videoChannel.name).to.equal('root_channel') + expect(playlist.videoChannel.displayName).to.equal('Main root channel') + } + } + }) + + it('Should create a playlist on server 2 and have the playlist on server 1 but not on server 3', async function () { + this.timeout(30000) + + { + const playlist = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist 2', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[1].store.channel.id + } + }) + playlistServer2Id1 = playlist.id + } + + { + const playlist = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist 3', + privacy: VideoPlaylistPrivacy.PUBLIC, + thumbnailfile: 'custom-thumbnail.jpg', + videoChannelId: servers[1].store.channel.id + } + }) + + playlistServer2Id2 = playlist.id + playlistServer2UUID2 = playlist.uuid + } + + for (const id of [ playlistServer2Id1, playlistServer2Id2 ]) { + await servers[1].playlists.addElement({ + playlistId: id, + attributes: { videoId: servers[1].store.videos[0].id, startTimestamp: 1, stopTimestamp: 2 } + }) + await servers[1].playlists.addElement({ + playlistId: id, + attributes: { videoId: servers[1].store.videos[1].id } + }) + } + + await waitJobs(servers) + await wait(3000) + + for (const server of [ servers[0], servers[1] ]) { + const body = await server.playlists.list({ start: 0, count: 5 }) + + const playlist2 = body.data.find(p => p.displayName === 'playlist 2') + expect(playlist2).to.not.be.undefined + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', playlist2.thumbnailPath) + + const playlist3 = body.data.find(p => p.displayName === 'playlist 3') + expect(playlist3).to.not.be.undefined + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', playlist3.thumbnailPath) + } + + const body = await servers[2].playlists.list({ start: 0, count: 5 }) + expect(body.data.find(p => p.displayName === 'playlist 2')).to.be.undefined + expect(body.data.find(p => p.displayName === 'playlist 3')).to.be.undefined + }) + + it('Should have the playlist on server 3 after a new follow', async function () { + this.timeout(30000) + + // Server 2 and server 3 follow each other + await doubleFollow(servers[1], servers[2]) + + const body = await servers[2].playlists.list({ start: 0, count: 5 }) + + const playlist2 = body.data.find(p => p.displayName === 'playlist 2') + expect(playlist2).to.not.be.undefined + await testImageGeneratedByFFmpeg(servers[2].url, 'thumbnail-playlist', playlist2.thumbnailPath) + + expect(body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined + }) + }) + + describe('List playlists', function () { + + it('Should correctly list the playlists', async function () { + this.timeout(30000) + + { + const body = await servers[2].playlists.list({ start: 1, count: 2, sort: 'createdAt' }) + expect(body.total).to.equal(3) + + const data = body.data + expect(data).to.have.lengthOf(2) + expect(data[0].displayName).to.equal('playlist 2') + expect(data[1].displayName).to.equal('playlist 3') + } + + { + const body = await servers[2].playlists.list({ start: 1, count: 2, sort: '-createdAt' }) + expect(body.total).to.equal(3) + + const data = body.data + expect(data).to.have.lengthOf(2) + expect(data[0].displayName).to.equal('playlist 2') + expect(data[1].displayName).to.equal('my super playlist') + } + }) + + it('Should list video channel playlists', async function () { + this.timeout(30000) + + { + const body = await commands[0].listByChannel({ handle: 'root_channel', start: 0, count: 2, sort: '-createdAt' }) + expect(body.total).to.equal(1) + + const data = body.data + expect(data).to.have.lengthOf(1) + expect(data[0].displayName).to.equal('my super playlist') + } + }) + + it('Should list account playlists', async function () { + this.timeout(30000) + + { + const body = await servers[1].playlists.listByAccount({ handle: 'root', start: 1, count: 2, sort: '-createdAt' }) + expect(body.total).to.equal(2) + + const data = body.data + expect(data).to.have.lengthOf(1) + expect(data[0].displayName).to.equal('playlist 2') + } + + { + const body = await servers[1].playlists.listByAccount({ handle: 'root', start: 1, count: 2, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const data = body.data + expect(data).to.have.lengthOf(1) + expect(data[0].displayName).to.equal('playlist 3') + } + + { + const body = await servers[1].playlists.listByAccount({ handle: 'root', sort: 'createdAt', search: '3' }) + expect(body.total).to.equal(1) + + const data = body.data + expect(data).to.have.lengthOf(1) + expect(data[0].displayName).to.equal('playlist 3') + } + + { + const body = await servers[1].playlists.listByAccount({ handle: 'root', sort: 'createdAt', search: '4' }) + expect(body.total).to.equal(0) + + const data = body.data + expect(data).to.have.lengthOf(0) + } + }) + }) + + describe('Playlist rights', function () { + let unlistedPlaylist: VideoPlaylistCreateResult + let privatePlaylist: VideoPlaylistCreateResult + + before(async function () { + this.timeout(30000) + + { + unlistedPlaylist = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist unlisted', + privacy: VideoPlaylistPrivacy.UNLISTED, + videoChannelId: servers[1].store.channel.id + } + }) + } + + { + privatePlaylist = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist private', + privacy: VideoPlaylistPrivacy.PRIVATE + } + }) + } + + await waitJobs(servers) + await wait(3000) + }) + + it('Should not list unlisted or private playlists', async function () { + for (const server of servers) { + const results = [ + await server.playlists.listByAccount({ handle: 'root@' + servers[1].host, sort: '-createdAt' }), + await server.playlists.list({ start: 0, count: 2, sort: '-createdAt' }) + ] + + expect(results[0].total).to.equal(2) + expect(results[1].total).to.equal(3) + + for (const body of results) { + const data = body.data + expect(data).to.have.lengthOf(2) + expect(data[0].displayName).to.equal('playlist 3') + expect(data[1].displayName).to.equal('playlist 2') + } + } + }) + + it('Should not get unlisted playlist using only the id', async function () { + await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 }) + }) + + it('Should get unlisted playlist using uuid or shortUUID', async function () { + await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid }) + await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID }) + }) + + it('Should not get private playlist without token', async function () { + for (const id of [ privatePlaylist.id, privatePlaylist.uuid, privatePlaylist.shortUUID ]) { + await servers[1].playlists.get({ playlistId: id, expectedStatus: 401 }) + } + }) + + it('Should get private playlist with a token', async function () { + for (const id of [ privatePlaylist.id, privatePlaylist.uuid, privatePlaylist.shortUUID ]) { + await servers[1].playlists.get({ token: servers[1].accessToken, playlistId: id }) + } + }) + }) + + describe('Update playlists', function () { + + it('Should update a playlist', async function () { + this.timeout(30000) + + await servers[1].playlists.update({ + attributes: { + displayName: 'playlist 3 updated', + description: 'description updated', + privacy: VideoPlaylistPrivacy.UNLISTED, + thumbnailfile: 'custom-thumbnail.jpg', + videoChannelId: servers[1].store.channel.id + }, + playlistId: playlistServer2Id2 + }) + + await waitJobs(servers) + + for (const server of servers) { + const playlist = await server.playlists.get({ playlistId: playlistServer2UUID2 }) + + expect(playlist.displayName).to.equal('playlist 3 updated') + expect(playlist.description).to.equal('description updated') + + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.UNLISTED) + expect(playlist.privacy.label).to.equal('Unlisted') + + expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) + expect(playlist.type.label).to.equal('Regular') + + expect(playlist.videosLength).to.equal(2) + + expect(playlist.ownerAccount.name).to.equal('root') + expect(playlist.ownerAccount.displayName).to.equal('root') + expect(playlist.videoChannel.name).to.equal('root_channel') + expect(playlist.videoChannel.displayName).to.equal('Main root channel') + } + }) + }) + + describe('Element timestamps', function () { + + it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () { + this.timeout(30000) + + const addVideo = (attributes: any) => { + return commands[0].addElement({ playlistId: playlistServer1Id, attributes }) + } + + const playlistDisplayName = 'playlist 4' + const playlist = await commands[0].create({ + attributes: { + displayName: playlistDisplayName, + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[0].store.channel.id + } + }) + + playlistServer1Id = playlist.id + playlistServer1DisplayName = playlistDisplayName + playlistServer1UUID = playlist.uuid + + await addVideo({ videoId: servers[0].store.videos[0].uuid, startTimestamp: 15, stopTimestamp: 28 }) + await addVideo({ videoId: servers[2].store.videos[1].uuid, startTimestamp: 35 }) + await addVideo({ videoId: servers[2].store.videos[2].uuid }) + { + const element = await addVideo({ videoId: servers[0].store.videos[3].uuid, stopTimestamp: 35 }) + playlistElementServer1Video4 = element.id + } + + { + const element = await addVideo({ videoId: servers[0].store.videos[4].uuid, startTimestamp: 45, stopTimestamp: 60 }) + playlistElementServer1Video5 = element.id + } + + { + const element = await addVideo({ videoId: nsfwVideoServer1, startTimestamp: 5 }) + playlistElementNSFW = element.id + + await addVideo({ videoId: nsfwVideoServer1, startTimestamp: 4 }) + await addVideo({ videoId: nsfwVideoServer1 }) + } + + await waitJobs(servers) + }) + + it('Should correctly list playlist videos', async function () { + this.timeout(30000) + + for (const server of servers) { + { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + + expect(body.total).to.equal(8) + + const videoElements = body.data + expect(videoElements).to.have.lengthOf(8) + + expect(videoElements[0].video.name).to.equal('video 0 server 1') + expect(videoElements[0].position).to.equal(1) + expect(videoElements[0].startTimestamp).to.equal(15) + expect(videoElements[0].stopTimestamp).to.equal(28) + + expect(videoElements[1].video.name).to.equal('video 1 server 3') + expect(videoElements[1].position).to.equal(2) + expect(videoElements[1].startTimestamp).to.equal(35) + expect(videoElements[1].stopTimestamp).to.be.null + + expect(videoElements[2].video.name).to.equal('video 2 server 3') + expect(videoElements[2].position).to.equal(3) + expect(videoElements[2].startTimestamp).to.be.null + expect(videoElements[2].stopTimestamp).to.be.null + + expect(videoElements[3].video.name).to.equal('video 3 server 1') + expect(videoElements[3].position).to.equal(4) + expect(videoElements[3].startTimestamp).to.be.null + expect(videoElements[3].stopTimestamp).to.equal(35) + + expect(videoElements[4].video.name).to.equal('video 4 server 1') + expect(videoElements[4].position).to.equal(5) + expect(videoElements[4].startTimestamp).to.equal(45) + expect(videoElements[4].stopTimestamp).to.equal(60) + + expect(videoElements[5].video.name).to.equal('NSFW video') + expect(videoElements[5].position).to.equal(6) + expect(videoElements[5].startTimestamp).to.equal(5) + expect(videoElements[5].stopTimestamp).to.be.null + + expect(videoElements[6].video.name).to.equal('NSFW video') + expect(videoElements[6].position).to.equal(7) + expect(videoElements[6].startTimestamp).to.equal(4) + expect(videoElements[6].stopTimestamp).to.be.null + + expect(videoElements[7].video.name).to.equal('NSFW video') + expect(videoElements[7].position).to.equal(8) + expect(videoElements[7].startTimestamp).to.be.null + expect(videoElements[7].stopTimestamp).to.be.null + } + + { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 2 }) + expect(body.data).to.have.lengthOf(2) + } + } + }) + }) + + describe('Element type', function () { + let groupUser1: PeerTubeServer[] + let groupWithoutToken1: PeerTubeServer[] + let group1: PeerTubeServer[] + let group2: PeerTubeServer[] + + let video1: string + let video2: string + let video3: string + + before(async function () { + this.timeout(60000) + + groupUser1 = [ Object.assign({}, servers[0], { accessToken: userTokenServer1 }) ] + groupWithoutToken1 = [ Object.assign({}, servers[0], { accessToken: undefined }) ] + group1 = [ servers[0] ] + group2 = [ servers[1], servers[2] ] + + const playlist = await commands[0].create({ + token: userTokenServer1, + attributes: { + displayName: 'playlist 56', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[0].store.channel.id + } + }) + + const playlistServer1Id2 = playlist.id + playlistServer1UUID2 = playlist.uuid + + const addVideo = (attributes: any) => { + return commands[0].addElement({ token: userTokenServer1, playlistId: playlistServer1Id2, attributes }) + } + + video1 = (await servers[0].videos.quickUpload({ name: 'video 89', token: userTokenServer1 })).uuid + video2 = (await servers[1].videos.quickUpload({ name: 'video 90' })).uuid + video3 = (await servers[0].videos.quickUpload({ name: 'video 91', nsfw: true })).uuid + + await waitJobs(servers) + + await addVideo({ videoId: video1, startTimestamp: 15, stopTimestamp: 28 }) + await addVideo({ videoId: video2, startTimestamp: 35 }) + await addVideo({ videoId: video3 }) + + await waitJobs(servers) + }) + + it('Should update the element type if the video is private/password protected', async function () { + this.timeout(20000) + + const name = 'video 89' + const position = 1 + + { + await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PRIVATE } }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + + { + await servers[0].videos.update({ + id: video1, + attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] } + }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + + { + await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + // We deleted the video, so even if we recreated it, the old entry is still deleted + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + }) + + it('Should update the element type if the video is blacklisted', async function () { + this.timeout(20000) + + const name = 'video 89' + const position = 1 + + { + await servers[0].blacklist.add({ videoId: video1, reason: 'reason', unfederate: true }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + + { + await servers[0].blacklist.remove({ videoId: video1 }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + // We deleted the video (because unfederated), so even if we recreated it, the old entry is still deleted + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + }) + + it('Should update the element type if the account or server of the video is blocked', async function () { + this.timeout(90000) + + const command = servers[0].blocklist + + const name = 'video 90' + const position = 2 + + { + await command.addToMyBlocklist({ token: userTokenServer1, account: 'root@' + servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + + await command.removeFromMyBlocklist({ token: userTokenServer1, account: 'root@' + servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + } + + { + await command.addToMyBlocklist({ token: userTokenServer1, server: servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + + await command.removeFromMyBlocklist({ token: userTokenServer1, server: servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + } + + { + await command.addToServerBlocklist({ account: 'root@' + servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + + await command.removeFromServerBlocklist({ account: 'root@' + servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + } + + { + await command.addToServerBlocklist({ server: servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + + await command.removeFromServerBlocklist({ server: servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + } + }) + }) + + describe('Managing playlist elements', function () { + + it('Should reorder the playlist', async function () { + this.timeout(30000) + + { + await commands[0].reorderElements({ + playlistId: playlistServer1Id, + attributes: { + startPosition: 2, + insertAfterPosition: 3 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + const names = body.data.map(v => v.video.name) + + expect(names).to.deep.equal([ + 'video 0 server 1', + 'video 2 server 3', + 'video 1 server 3', + 'video 3 server 1', + 'video 4 server 1', + 'NSFW video', + 'NSFW video', + 'NSFW video' + ]) + } + } + + { + await commands[0].reorderElements({ + playlistId: playlistServer1Id, + attributes: { + startPosition: 1, + reorderLength: 3, + insertAfterPosition: 4 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + const names = body.data.map(v => v.video.name) + + expect(names).to.deep.equal([ + 'video 3 server 1', + 'video 0 server 1', + 'video 2 server 3', + 'video 1 server 3', + 'video 4 server 1', + 'NSFW video', + 'NSFW video', + 'NSFW video' + ]) + } + } + + { + await commands[0].reorderElements({ + playlistId: playlistServer1Id, + attributes: { + startPosition: 6, + insertAfterPosition: 3 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const { data: elements } = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + const names = elements.map(v => v.video.name) + + expect(names).to.deep.equal([ + 'video 3 server 1', + 'video 0 server 1', + 'video 2 server 3', + 'NSFW video', + 'video 1 server 3', + 'video 4 server 1', + 'NSFW video', + 'NSFW video' + ]) + + for (let i = 1; i <= elements.length; i++) { + expect(elements[i - 1].position).to.equal(i) + } + } + } + }) + + it('Should update startTimestamp/endTimestamp of some elements', async function () { + this.timeout(30000) + + await commands[0].updateElement({ + playlistId: playlistServer1Id, + elementId: playlistElementServer1Video4, + attributes: { + startTimestamp: 1 + } + }) + + await commands[0].updateElement({ + playlistId: playlistServer1Id, + elementId: playlistElementServer1Video5, + attributes: { + stopTimestamp: null + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const { data: elements } = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + + expect(elements[0].video.name).to.equal('video 3 server 1') + expect(elements[0].position).to.equal(1) + expect(elements[0].startTimestamp).to.equal(1) + expect(elements[0].stopTimestamp).to.equal(35) + + expect(elements[5].video.name).to.equal('video 4 server 1') + expect(elements[5].position).to.equal(6) + expect(elements[5].startTimestamp).to.equal(45) + expect(elements[5].stopTimestamp).to.be.null + } + }) + + it('Should check videos existence in my playlist', async function () { + const videoIds = [ + servers[0].store.videos[0].id, + 42000, + servers[0].store.videos[3].id, + 43000, + servers[0].store.videos[4].id + ] + const obj = await commands[0].videosExist({ videoIds }) + + { + const elem = obj[servers[0].store.videos[0].id] + expect(elem).to.have.lengthOf(1) + expect(elem[0].playlistElementId).to.exist + expect(elem[0].playlistDisplayName).to.equal(playlistServer1DisplayName) + expect(elem[0].playlistShortUUID).to.equal(uuidToShort(playlistServer1UUID)) + expect(elem[0].playlistId).to.equal(playlistServer1Id) + expect(elem[0].startTimestamp).to.equal(15) + expect(elem[0].stopTimestamp).to.equal(28) + } + + { + const elem = obj[servers[0].store.videos[3].id] + expect(elem).to.have.lengthOf(1) + expect(elem[0].playlistElementId).to.equal(playlistElementServer1Video4) + expect(elem[0].playlistDisplayName).to.equal(playlistServer1DisplayName) + expect(elem[0].playlistShortUUID).to.equal(uuidToShort(playlistServer1UUID)) + expect(elem[0].playlistId).to.equal(playlistServer1Id) + expect(elem[0].startTimestamp).to.equal(1) + expect(elem[0].stopTimestamp).to.equal(35) + } + + { + const elem = obj[servers[0].store.videos[4].id] + expect(elem).to.have.lengthOf(1) + expect(elem[0].playlistId).to.equal(playlistServer1Id) + expect(elem[0].playlistDisplayName).to.equal(playlistServer1DisplayName) + expect(elem[0].playlistShortUUID).to.equal(uuidToShort(playlistServer1UUID)) + expect(elem[0].startTimestamp).to.equal(45) + expect(elem[0].stopTimestamp).to.equal(null) + } + + expect(obj[42000]).to.have.lengthOf(0) + expect(obj[43000]).to.have.lengthOf(0) + }) + + it('Should automatically update updatedAt field of playlists', async function () { + const server = servers[1] + const videoId = servers[1].store.videos[5].id + + async function getPlaylistNames () { + const { data } = await server.playlists.listByAccount({ token: server.accessToken, handle: 'root', sort: '-updatedAt' }) + + return data.map(p => p.displayName) + } + + const attributes = { videoId } + const element1 = await server.playlists.addElement({ playlistId: playlistServer2Id1, attributes }) + const element2 = await server.playlists.addElement({ playlistId: playlistServer2Id2, attributes }) + + const names1 = await getPlaylistNames() + expect(names1[0]).to.equal('playlist 3 updated') + expect(names1[1]).to.equal('playlist 2') + + await server.playlists.removeElement({ playlistId: playlistServer2Id1, elementId: element1.id }) + + const names2 = await getPlaylistNames() + expect(names2[0]).to.equal('playlist 2') + expect(names2[1]).to.equal('playlist 3 updated') + + await server.playlists.removeElement({ playlistId: playlistServer2Id2, elementId: element2.id }) + + const names3 = await getPlaylistNames() + expect(names3[0]).to.equal('playlist 3 updated') + expect(names3[1]).to.equal('playlist 2') + }) + + it('Should delete some elements', async function () { + this.timeout(30000) + + await commands[0].removeElement({ playlistId: playlistServer1Id, elementId: playlistElementServer1Video4 }) + await commands[0].removeElement({ playlistId: playlistServer1Id, elementId: playlistElementNSFW }) + + await waitJobs(servers) + + for (const server of servers) { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + expect(body.total).to.equal(6) + + const elements = body.data + expect(elements).to.have.lengthOf(6) + + expect(elements[0].video.name).to.equal('video 0 server 1') + expect(elements[0].position).to.equal(1) + + expect(elements[1].video.name).to.equal('video 2 server 3') + expect(elements[1].position).to.equal(2) + + expect(elements[2].video.name).to.equal('video 1 server 3') + expect(elements[2].position).to.equal(3) + + expect(elements[3].video.name).to.equal('video 4 server 1') + expect(elements[3].position).to.equal(4) + + expect(elements[4].video.name).to.equal('NSFW video') + expect(elements[4].position).to.equal(5) + + expect(elements[5].video.name).to.equal('NSFW video') + expect(elements[5].position).to.equal(6) + } + }) + + it('Should be able to create a public playlist, and set it to private', async function () { + this.timeout(30000) + + const videoPlaylistIds = await commands[0].create({ + attributes: { + displayName: 'my super public playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[0].store.channel.id + } + }) + + await waitJobs(servers) + + for (const server of servers) { + await server.playlists.get({ playlistId: videoPlaylistIds.uuid, expectedStatus: HttpStatusCode.OK_200 }) + } + + const attributes = { privacy: VideoPlaylistPrivacy.PRIVATE } + await commands[0].update({ playlistId: videoPlaylistIds.id, attributes }) + + await waitJobs(servers) + + for (const server of [ servers[1], servers[2] ]) { + await server.playlists.get({ playlistId: videoPlaylistIds.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + + await commands[0].get({ playlistId: videoPlaylistIds.uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await commands[0].get({ token: servers[0].accessToken, playlistId: videoPlaylistIds.uuid, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('Playlist deletion', function () { + + it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () { + this.timeout(30000) + + await commands[0].delete({ playlistId: playlistServer1Id }) + + await waitJobs(servers) + + for (const server of servers) { + await server.playlists.get({ playlistId: playlistServer1UUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + }) + + it('Should have deleted the thumbnail on server 1, 2 and 3', async function () { + this.timeout(30000) + + for (const server of servers) { + await checkPlaylistFilesWereRemoved(playlistServer1UUID, server) + } + }) + + it('Should unfollow servers 1 and 2 and hide their playlists', async function () { + this.timeout(30000) + + const finder = (data: VideoPlaylist[]) => data.find(p => p.displayName === 'my super playlist') + + { + const body = await servers[2].playlists.list({ start: 0, count: 5 }) + expect(body.total).to.equal(3) + + expect(finder(body.data)).to.not.be.undefined + } + + await servers[2].follows.unfollow({ target: servers[0] }) + + { + const body = await servers[2].playlists.list({ start: 0, count: 5 }) + expect(body.total).to.equal(1) + + expect(finder(body.data)).to.be.undefined + } + }) + + it('Should delete a channel and put the associated playlist in private mode', async function () { + this.timeout(30000) + + const channel = await servers[0].channels.create({ attributes: { name: 'super_channel', displayName: 'super channel' } }) + + const playlistCreated = await commands[0].create({ + attributes: { + displayName: 'channel playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: channel.id + } + }) + + await waitJobs(servers) + + await servers[0].channels.delete({ channelName: 'super_channel' }) + + await waitJobs(servers) + + const body = await commands[0].get({ token: servers[0].accessToken, playlistId: playlistCreated.uuid }) + expect(body.displayName).to.equal('channel playlist') + expect(body.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE) + + await servers[1].playlists.get({ playlistId: playlistCreated.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should delete an account and delete its playlists', async function () { + this.timeout(30000) + + const { userId, token } = await servers[0].users.generate('user_1') + + const { videoChannels } = await servers[0].users.getMyInfo({ token }) + const userChannel = videoChannels[0] + + await commands[0].create({ + attributes: { + displayName: 'playlist to be deleted', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: userChannel.id + } + }) + + await waitJobs(servers) + + const finder = (data: VideoPlaylist[]) => data.find(p => p.displayName === 'playlist to be deleted') + + { + for (const server of [ servers[0], servers[1] ]) { + const body = await server.playlists.list({ start: 0, count: 15 }) + + expect(finder(body.data)).to.not.be.undefined + } + } + + await servers[0].users.remove({ userId }) + await waitJobs(servers) + + { + for (const server of [ servers[0], servers[1] ]) { + const body = await server.playlists.list({ start: 0, count: 15 }) + + expect(finder(body.data)).to.be.undefined + } + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-privacy.ts b/packages/tests/src/api/videos/video-privacy.ts new file mode 100644 index 000000000..9171463a4 --- /dev/null +++ b/packages/tests/src/api/videos/video-privacy.ts @@ -0,0 +1,294 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video privacy', function () { + const servers: PeerTubeServer[] = [] + let anotherUserToken: string + + let privateVideoId: number + let privateVideoUUID: string + + let internalVideoId: number + let internalVideoUUID: string + + let unlistedVideo: VideoCreateResult + let nonFederatedUnlistedVideoUUID: string + + let now: number + + const dontFederateUnlistedConfig = { + federation: { + videos: { + federate_unlisted: false + } + } + } + + before(async function () { + this.timeout(50000) + + // Run servers + servers.push(await createSingleServer(1, dontFederateUnlistedConfig)) + servers.push(await createSingleServer(2)) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + describe('Private and internal videos', function () { + + it('Should upload a private and internal videos on server 1', async function () { + this.timeout(50000) + + for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { + const attributes = { privacy } + await servers[0].videos.upload({ attributes }) + } + + await waitJobs(servers) + }) + + it('Should not have these private and internal videos on server 2', async function () { + const { total, data } = await servers[1].videos.list() + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + }) + + it('Should not list the private and internal videos for an unauthenticated user on server 1', async function () { + const { total, data } = await servers[0].videos.list() + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + }) + + it('Should not list the private video and list the internal video for an authenticated user on server 1', async function () { + const { total, data } = await servers[0].videos.listWithToken() + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + expect(data[0].privacy.id).to.equal(VideoPrivacy.INTERNAL) + }) + + it('Should list my (private and internal) videos', async function () { + const { total, data } = await servers[0].videos.listMyVideos() + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + const privateVideo = data.find(v => v.privacy.id === VideoPrivacy.PRIVATE) + privateVideoId = privateVideo.id + privateVideoUUID = privateVideo.uuid + + const internalVideo = data.find(v => v.privacy.id === VideoPrivacy.INTERNAL) + internalVideoId = internalVideo.id + internalVideoUUID = internalVideo.uuid + }) + + it('Should not be able to watch the private/internal video with non authenticated user', async function () { + await servers[0].videos.get({ id: privateVideoUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await servers[0].videos.get({ id: internalVideoUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not be able to watch the private video with another user', async function () { + const user = { + username: 'hello', + password: 'super password' + } + await servers[0].users.create({ username: user.username, password: user.password }) + + anotherUserToken = await servers[0].login.getAccessToken(user) + + await servers[0].videos.getWithToken({ + token: anotherUserToken, + id: privateVideoUUID, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should be able to watch the internal video with another user', async function () { + await servers[0].videos.getWithToken({ token: anotherUserToken, id: internalVideoUUID }) + }) + + it('Should be able to watch the private video with the correct user', async function () { + await servers[0].videos.getWithToken({ id: privateVideoUUID }) + }) + }) + + describe('Unlisted videos', function () { + + it('Should upload an unlisted video on server 2', async function () { + this.timeout(120000) + + const attributes = { + name: 'unlisted video', + privacy: VideoPrivacy.UNLISTED + } + await servers[1].videos.upload({ attributes }) + + // Server 2 has transcoding enabled + await waitJobs(servers) + }) + + it('Should not have this unlisted video listed on server 1 and 2', async function () { + for (const server of servers) { + const { total, data } = await server.videos.list() + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + }) + + it('Should list my (unlisted) videos', async function () { + const { total, data } = await servers[1].videos.listMyVideos() + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + unlistedVideo = data[0] + }) + + it('Should not be able to get this unlisted video using its id', async function () { + await servers[1].videos.get({ id: unlistedVideo.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should be able to get this unlisted video using its uuid/shortUUID', async function () { + for (const server of servers) { + for (const id of [ unlistedVideo.uuid, unlistedVideo.shortUUID ]) { + const video = await server.videos.get({ id }) + + expect(video.name).to.equal('unlisted video') + } + } + }) + + it('Should upload a non-federating unlisted video to server 1', async function () { + this.timeout(30000) + + const attributes = { + name: 'unlisted video', + privacy: VideoPrivacy.UNLISTED + } + await servers[0].videos.upload({ attributes }) + + await waitJobs(servers) + }) + + it('Should list my new unlisted video', async function () { + const { total, data } = await servers[0].videos.listMyVideos() + + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + + nonFederatedUnlistedVideoUUID = data[0].uuid + }) + + it('Should be able to get non-federated unlisted video from origin', async function () { + const video = await servers[0].videos.get({ id: nonFederatedUnlistedVideoUUID }) + + expect(video.name).to.equal('unlisted video') + }) + + it('Should not be able to get non-federated unlisted video from federated server', async function () { + await servers[1].videos.get({ id: nonFederatedUnlistedVideoUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('Privacy update', function () { + + it('Should update the private and internal videos to public on server 1', async function () { + this.timeout(100000) + + now = Date.now() + + { + const attributes = { + name: 'private video becomes public', + privacy: VideoPrivacy.PUBLIC + } + + await servers[0].videos.update({ id: privateVideoId, attributes }) + } + + { + const attributes = { + name: 'internal video becomes public', + privacy: VideoPrivacy.PUBLIC + } + await servers[0].videos.update({ id: internalVideoId, attributes }) + } + + await wait(10000) + await waitJobs(servers) + }) + + it('Should have this new public video listed on server 1 and 2', async function () { + for (const server of servers) { + const { total, data } = await server.videos.list() + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + const privateVideo = data.find(v => v.name === 'private video becomes public') + const internalVideo = data.find(v => v.name === 'internal video becomes public') + + expect(privateVideo).to.not.be.undefined + expect(internalVideo).to.not.be.undefined + + expect(new Date(privateVideo.publishedAt).getTime()).to.be.at.least(now) + // We don't change the publish date of internal videos + expect(new Date(internalVideo.publishedAt).getTime()).to.be.below(now) + + expect(privateVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) + expect(internalVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) + } + }) + + it('Should set these videos as private and internal', async function () { + await servers[0].videos.update({ id: internalVideoId, attributes: { privacy: VideoPrivacy.PRIVATE } }) + await servers[0].videos.update({ id: privateVideoId, attributes: { privacy: VideoPrivacy.INTERNAL } }) + + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + + { + const { total, data } = await servers[0].videos.listMyVideos() + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + + const privateVideo = data.find(v => v.name === 'private video becomes public') + const internalVideo = data.find(v => v.name === 'internal video becomes public') + + expect(privateVideo).to.not.be.undefined + expect(internalVideo).to.not.be.undefined + + expect(privateVideo.privacy.id).to.equal(VideoPrivacy.INTERNAL) + expect(internalVideo.privacy.id).to.equal(VideoPrivacy.PRIVATE) + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-schedule-update.ts b/packages/tests/src/api/videos/video-schedule-update.ts new file mode 100644 index 000000000..96d71933e --- /dev/null +++ b/packages/tests/src/api/videos/video-schedule-update.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +function in10Seconds () { + const now = new Date() + now.setSeconds(now.getSeconds() + 10) + + return now +} + +describe('Test video update scheduler', function () { + let servers: PeerTubeServer[] = [] + let video2UUID: string + + before(async function () { + this.timeout(30000) + + // Run servers + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should upload a video and schedule an update in 10 seconds', async function () { + const attributes = { + name: 'video 1', + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: in10Seconds().toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + + await servers[0].videos.upload({ attributes }) + + await waitJobs(servers) + }) + + it('Should not list the video (in privacy mode)', async function () { + for (const server of servers) { + const { total } = await server.videos.list() + + expect(total).to.equal(0) + } + }) + + it('Should have my scheduled video in my account videos', async function () { + const { total, data } = await servers[0].videos.listMyVideos() + expect(total).to.equal(1) + + const videoFromList = data[0] + const videoFromGet = await servers[0].videos.getWithToken({ id: videoFromList.uuid }) + + for (const video of [ videoFromList, videoFromGet ]) { + expect(video.name).to.equal('video 1') + expect(video.privacy.id).to.equal(VideoPrivacy.PRIVATE) + expect(new Date(video.scheduledUpdate.updateAt)).to.be.above(new Date()) + expect(video.scheduledUpdate.privacy).to.equal(VideoPrivacy.PUBLIC) + } + }) + + it('Should wait some seconds and have the video in public privacy', async function () { + this.timeout(50000) + + await wait(15000) + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + + expect(total).to.equal(1) + expect(data[0].name).to.equal('video 1') + } + }) + + it('Should upload a video without scheduling an update', async function () { + const attributes = { + name: 'video 2', + privacy: VideoPrivacy.PRIVATE + } + + const { uuid } = await servers[0].videos.upload({ attributes }) + video2UUID = uuid + + await waitJobs(servers) + }) + + it('Should update a video by scheduling an update', async function () { + const attributes = { + name: 'video 2 updated', + scheduleUpdate: { + updateAt: in10Seconds().toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + + await servers[0].videos.update({ id: video2UUID, attributes }) + await waitJobs(servers) + }) + + it('Should not display the updated video', async function () { + for (const server of servers) { + const { total } = await server.videos.list() + + expect(total).to.equal(1) + } + }) + + it('Should have my scheduled updated video in my account videos', async function () { + const { total, data } = await servers[0].videos.listMyVideos() + expect(total).to.equal(2) + + const video = data.find(v => v.uuid === video2UUID) + expect(video).not.to.be.undefined + + expect(video.name).to.equal('video 2 updated') + expect(video.privacy.id).to.equal(VideoPrivacy.PRIVATE) + + expect(new Date(video.scheduledUpdate.updateAt)).to.be.above(new Date()) + expect(video.scheduledUpdate.privacy).to.equal(VideoPrivacy.PUBLIC) + }) + + it('Should wait some seconds and have the updated video in public privacy', async function () { + this.timeout(20000) + + await wait(15000) + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + expect(total).to.equal(2) + + const video = data.find(v => v.uuid === video2UUID) + expect(video).not.to.be.undefined + expect(video.name).to.equal('video 2 updated') + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-source.ts b/packages/tests/src/api/videos/video-source.ts new file mode 100644 index 000000000..efe8c3802 --- /dev/null +++ b/packages/tests/src/api/videos/video-source.ts @@ -0,0 +1,448 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { expect } from 'chai' +import { getAllFiles } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { expectStartWith } from '@tests/shared/checks.js' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + makeRawRequest, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test a video file replacement', function () { + let servers: PeerTubeServer[] = [] + + let replaceDate: Date + let userToken: string + let uuid: string + + before(async function () { + this.timeout(50000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + await servers[0].config.enableFileUpdate() + + userToken = await servers[0].users.generateUserAndToken('user1') + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + describe('Getting latest video source', () => { + const fixture = 'video_short.webm' + const uuids: string[] = [] + + it('Should get the source filename with legacy upload', async function () { + this.timeout(30000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'legacy' }) + uuids.push(uuid) + + const source = await servers[0].videos.getSource({ id: uuid }) + expect(source.filename).to.equal(fixture) + }) + + it('Should get the source filename with resumable upload', async function () { + this.timeout(30000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'resumable' }) + uuids.push(uuid) + + const source = await servers[0].videos.getSource({ id: uuid }) + expect(source.filename).to.equal(fixture) + }) + + after(async function () { + this.timeout(60000) + + for (const uuid of uuids) { + await servers[0].videos.remove({ id: uuid }) + } + + await waitJobs(servers) + }) + }) + + describe('Updating video source', function () { + + describe('Filesystem', function () { + + it('Should replace a video file with transcoding disabled', async function () { + this.timeout(120000) + + await servers[0].config.disableTranscoding() + + const { uuid } = await servers[0].videos.quickUpload({ name: 'fs without transcoding', fixture: 'video_short_720p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(1) + expect(files[0].resolution.id).to.equal(720) + } + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(1) + expect(files[0].resolution.id).to.equal(360) + } + }) + + it('Should replace a video file with transcoding enabled', async function () { + this.timeout(120000) + + const previousPaths: string[] = [] + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + + const { uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'fs with transcoding', fixture: 'video_short_720p.mp4' }) + uuid = videoUUID + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + expect(video.inputFileUpdatedAt).to.be.null + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(6 * 2) + + // Grab old paths to ensure we'll regenerate + + previousPaths.push(video.previewPath) + previousPaths.push(video.thumbnailPath) + + for (const file of files) { + previousPaths.push(file.fileUrl) + previousPaths.push(file.torrentUrl) + previousPaths.push(file.metadataUrl) + + const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl }) + previousPaths.push(JSON.stringify(metadata)) + } + + const { storyboards } = await server.storyboard.list({ id: uuid }) + for (const s of storyboards) { + previousPaths.push(s.storyboardPath) + } + } + + replaceDate = new Date() + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + expect(video.inputFileUpdatedAt).to.not.be.null + expect(new Date(video.inputFileUpdatedAt)).to.be.above(replaceDate) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(4 * 2) + + expect(previousPaths).to.not.include(video.previewPath) + expect(previousPaths).to.not.include(video.thumbnailPath) + + await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + + for (const file of files) { + expect(previousPaths).to.not.include(file.fileUrl) + expect(previousPaths).to.not.include(file.torrentUrl) + expect(previousPaths).to.not.include(file.metadataUrl) + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 }) + + const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl }) + expect(previousPaths).to.not.include(JSON.stringify(metadata)) + } + + const { storyboards } = await server.storyboard.list({ id: uuid }) + for (const s of storyboards) { + expect(previousPaths).to.not.include(s.storyboardPath) + + await makeGetRequest({ url: server.url, path: s.storyboardPath, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + await servers[0].config.enableMinimumTranscoding() + }) + + it('Should have cleaned up old files', async function () { + { + const count = await servers[0].servers.countFiles('storyboards') + expect(count).to.equal(2) + } + + { + const count = await servers[0].servers.countFiles('web-videos') + expect(count).to.equal(5 + 1) // +1 for private directory + } + + { + const count = await servers[0].servers.countFiles('streaming-playlists/hls') + expect(count).to.equal(1 + 1) // +1 for private directory + } + + { + const count = await servers[0].servers.countFiles('torrents') + expect(count).to.equal(9) + } + }) + + it('Should have the correct source input', async function () { + const source = await servers[0].videos.getSource({ id: uuid }) + + expect(source.filename).to.equal('video_short_360p.mp4') + expect(new Date(source.createdAt)).to.be.above(replaceDate) + }) + + it('Should not have regenerated miniatures that were previously uploaded', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.upload({ + attributes: { + name: 'custom miniatures', + thumbnailfile: 'custom-thumbnail.jpg', + previewfile: 'custom-preview.jpg' + } + }) + + await waitJobs(servers) + + const previousPaths: string[] = [] + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + previousPaths.push(video.previewPath) + previousPaths.push(video.thumbnailPath) + + await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + } + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + expect(previousPaths).to.include(video.previewPath) + expect(previousPaths).to.include(video.thumbnailPath) + + await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + }) + + describe('Autoblacklist', function () { + + function updateAutoBlacklist (enabled: boolean) { + return servers[0].config.updateExistingSubConfig({ + newConfig: { + autoBlacklist: { + videos: { + ofUsers: { + enabled + } + } + } + } + }) + } + + async function expectBlacklist (uuid: string, value: boolean) { + const video = await servers[0].videos.getWithToken({ id: uuid }) + + expect(video.blacklisted).to.equal(value) + } + + before(async function () { + await updateAutoBlacklist(true) + }) + + it('Should auto blacklist an unblacklisted video after file replacement', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' }) + await waitJobs(servers) + await expectBlacklist(uuid, true) + + await servers[0].blacklist.remove({ videoId: uuid }) + await expectBlacklist(uuid, false) + + await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + await expectBlacklist(uuid, true) + }) + + it('Should auto blacklist an already blacklisted video after file replacement', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' }) + await waitJobs(servers) + await expectBlacklist(uuid, true) + + await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + await expectBlacklist(uuid, true) + }) + + it('Should not auto blacklist if auto blacklist has been disabled between the upload and the replacement', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' }) + await waitJobs(servers) + await expectBlacklist(uuid, true) + + await servers[0].blacklist.remove({ videoId: uuid }) + await expectBlacklist(uuid, false) + + await updateAutoBlacklist(false) + + await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short1.webm' }) + await waitJobs(servers) + + await expectBlacklist(uuid, false) + }) + }) + + describe('With object storage enabled', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(120000) + + const configOverride = objectStorage.getDefaultMockConfig() + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].kill() + await servers[0].run(configOverride) + }) + + it('Should replace a video file with transcoding disabled', async function () { + this.timeout(120000) + + await servers[0].config.disableTranscoding() + + const { uuid } = await servers[0].videos.quickUpload({ + name: 'object storage without transcoding', + fixture: 'video_short_720p.mp4' + }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(1) + expect(files[0].resolution.id).to.equal(720) + expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(1) + expect(files[0].resolution.id).to.equal(360) + expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + }) + + it('Should replace a video file with transcoding enabled', async function () { + this.timeout(120000) + + const previousPaths: string[] = [] + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + + const { uuid: videoUUID } = await servers[0].videos.quickUpload({ + name: 'object storage with transcoding', + fixture: 'video_short_360p.mp4' + }) + uuid = videoUUID + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(4 * 2) + + for (const file of files) { + previousPaths.push(file.fileUrl) + } + + for (const file of video.files) { + expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + + for (const file of video.streamingPlaylists[0].files) { + expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + } + } + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_240p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(3 * 2) + + for (const file of files) { + expect(previousPaths).to.not.include(file.fileUrl) + } + + for (const file of video.files) { + expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + + for (const file of video.streamingPlaylists[0].files) { + expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + } + } + }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-static-file-privacy.ts b/packages/tests/src/api/videos/video-static-file-privacy.ts new file mode 100644 index 000000000..7c8d14815 --- /dev/null +++ b/packages/tests/src/api/videos/video-static-file-privacy.ts @@ -0,0 +1,602 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { decode } from 'magnet-uri' +import { getAllFiles, wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, HttpStatusCodeType, LiveVideo, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + findExternalSavedVideo, + makeRawRequest, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' +import { expectStartWith } from '@tests/shared/checks.js' +import { checkVideoFileTokenReinjection } from '@tests/shared/streaming-playlists.js' +import { parseTorrentVideo } from '@tests/shared/webtorrent.js' + +describe('Test video static file privacy', function () { + let server: PeerTubeServer + let userToken: string + + before(async function () { + this.timeout(50000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + userToken = await server.users.generateUserAndToken('user1') + }) + + describe('VOD static file path', function () { + + function runSuite () { + + async function checkPrivateFiles (uuid: string) { + const video = await server.videos.getWithToken({ id: uuid }) + + for (const file of video.files) { + expect(file.fileDownloadUrl).to.not.include('/private/') + expectStartWith(file.fileUrl, server.url + '/static/web-videos/private/') + + const torrent = await parseTorrentVideo(server, file) + expect(torrent.urlList).to.have.lengthOf(0) + + const magnet = decode(file.magnetUri) + expect(magnet.urlList).to.have.lengthOf(0) + + await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = video.streamingPlaylists[0] + if (hls) { + expectStartWith(hls.playlistUrl, server.url + '/static/streaming-playlists/hls/private/') + expectStartWith(hls.segmentsSha256Url, server.url + '/static/streaming-playlists/hls/private/') + + await makeRawRequest({ url: hls.playlistUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + async function checkPublicFiles (uuid: string) { + const video = await server.videos.get({ id: uuid }) + + for (const file of getAllFiles(video)) { + expect(file.fileDownloadUrl).to.not.include('/private/') + expect(file.fileUrl).to.not.include('/private/') + + const torrent = await parseTorrentVideo(server, file) + expect(torrent.urlList[0]).to.not.include('private') + + const magnet = decode(file.magnetUri) + expect(magnet.urlList[0]).to.not.include('private') + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: torrent.urlList[0], expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: magnet.urlList[0], expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = video.streamingPlaylists[0] + if (hls) { + expect(hls.playlistUrl).to.not.include('private') + expect(hls.segmentsSha256Url).to.not.include('private') + + await makeRawRequest({ url: hls.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + it('Should upload a private/internal/password protected video and have a private static path', async function () { + this.timeout(120000) + + for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy }) + await waitJobs([ server ]) + + await checkPrivateFiles(uuid) + } + + const { uuid } = await server.videos.quickUpload({ + name: 'video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ 'my super password' ] + }) + await waitJobs([ server ]) + + await checkPrivateFiles(uuid) + }) + + it('Should upload a public video and update it as private/internal to have a private static path', async function () { + this.timeout(120000) + + for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PUBLIC }) + await waitJobs([ server ]) + + await server.videos.update({ id: uuid, attributes: { privacy } }) + await waitJobs([ server ]) + + await checkPrivateFiles(uuid) + } + }) + + it('Should upload a private video and update it to unlisted to have a public static path', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + await waitJobs([ server ]) + + await server.videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.UNLISTED } }) + await waitJobs([ server ]) + + await checkPublicFiles(uuid) + }) + + it('Should upload an internal video and update it to public to have a public static path', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) + await waitJobs([ server ]) + + await server.videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + await waitJobs([ server ]) + + await checkPublicFiles(uuid) + }) + + it('Should upload an internal video and schedule a public publish', async function () { + this.timeout(120000) + + const attributes = { + name: 'video', + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: new Date(Date.now() + 1000).toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + + const { uuid } = await server.videos.upload({ attributes }) + + await waitJobs([ server ]) + await wait(1000) + await server.debug.sendCommand({ body: { command: 'process-update-videos-scheduler' } }) + + await waitJobs([ server ]) + + await checkPublicFiles(uuid) + }) + } + + describe('Without transcoding', function () { + runSuite() + }) + + describe('With transcoding', function () { + + before(async function () { + await server.config.enableMinimumTranscoding() + }) + + runSuite() + }) + }) + + describe('VOD static file right check', function () { + let unrelatedFileToken: string + + async function checkVideoFiles (options: { + id: string + expectedStatus: HttpStatusCodeType + token: string + videoFileToken: string + videoPassword?: string + }) { + const { id, expectedStatus, token, videoFileToken, videoPassword } = options + + const video = await server.videos.getWithToken({ id }) + + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.fileUrl, token, expectedStatus }) + await makeRawRequest({ url: file.fileDownloadUrl, token, expectedStatus }) + + await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus }) + await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus }) + + if (videoPassword) { + const headers = { 'x-peertube-video-password': videoPassword } + await makeRawRequest({ url: file.fileUrl, headers, expectedStatus }) + await makeRawRequest({ url: file.fileDownloadUrl, headers, expectedStatus }) + } + } + + const hls = video.streamingPlaylists[0] + await makeRawRequest({ url: hls.playlistUrl, token, expectedStatus }) + await makeRawRequest({ url: hls.segmentsSha256Url, token, expectedStatus }) + + await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus }) + await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus }) + + if (videoPassword) { + const headers = { 'x-peertube-video-password': videoPassword } + await makeRawRequest({ url: hls.playlistUrl, token: null, headers, expectedStatus }) + await makeRawRequest({ url: hls.segmentsSha256Url, token: null, headers, expectedStatus }) + } + } + + before(async function () { + await server.config.enableMinimumTranscoding() + + const { uuid } = await server.videos.quickUpload({ name: 'another video' }) + unrelatedFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + }) + + it('Should not be able to access a private video files without OAuth token and file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) + }) + + it('Should not be able to access password protected video files without OAuth token, file token and password', async function () { + this.timeout(120000) + const videoPassword = 'my super password' + + const { uuid } = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + await waitJobs([ server ]) + + await checkVideoFiles({ + id: uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + token: null, + videoFileToken: null, + videoPassword: null + }) + }) + + it('Should not be able to access an password video files with incorrect OAuth token, file token and password', async function () { + this.timeout(120000) + const videoPassword = 'my super password' + + const { uuid } = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + await waitJobs([ server ]) + + await checkVideoFiles({ + id: uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + token: userToken, + videoFileToken: unrelatedFileToken, + videoPassword: 'incorrectPassword' + }) + }) + + it('Should not be able to access an private video files without appropriate OAuth token and file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + await waitJobs([ server ]) + + await checkVideoFiles({ + id: uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + token: userToken, + videoFileToken: unrelatedFileToken + }) + }) + + it('Should be able to access a private video files with appropriate OAuth token or file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) + }) + + it('Should be able to access a password protected video files with appropriate OAuth token or file token', async function () { + this.timeout(120000) + const videoPassword = 'my super password' + + const { uuid } = await server.videos.quickUpload({ + name: 'video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + + const videoFileToken = await server.videoToken.getVideoFileToken({ token: null, videoId: uuid, videoPassword }) + + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken, videoPassword }) + }) + + it('Should reinject video file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + await waitJobs([ server ]) + + { + const video = await server.videos.getWithToken({ id: uuid }) + const hls = video.streamingPlaylists[0] + const query = { videoFileToken } + const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 }) + + expect(text).to.not.include(videoFileToken) + } + + { + await checkVideoFileTokenReinjection({ + server, + videoUUID: uuid, + videoFileToken, + resolutions: [ 240, 720 ], + isLive: false + }) + } + }) + + it('Should be able to access a private video of another user with an admin OAuth token or file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', token: userToken, privacy: VideoPrivacy.PRIVATE }) + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) + }) + }) + + describe('Live static file path and check', function () { + let normalLiveId: string + let normalLive: LiveVideo + + let permanentLiveId: string + let permanentLive: LiveVideo + + let passwordProtectedLiveId: string + let passwordProtectedLive: LiveVideo + + const correctPassword = 'my super password' + + let unrelatedFileToken: string + + async function checkLiveFiles (options: { live: LiveVideo, liveId: string, videoPassword?: string }) { + const { live, liveId, videoPassword } = options + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await server.live.waitUntilPublished({ videoId: liveId }) + + const video = await server.videos.getWithToken({ id: liveId }) + + const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) + + const hls = video.streamingPlaylists[0] + + for (const url of [ hls.playlistUrl, hls.segmentsSha256Url ]) { + expectStartWith(url, server.url + '/static/streaming-playlists/hls/private/') + + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + + if (videoPassword) { + await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ + url, + headers: { 'x-peertube-video-password': 'incorrectPassword' }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + + } + + await stopFfmpeg(ffmpegCommand) + } + + async function checkReplay (replay: VideoDetails) { + const fileToken = await server.videoToken.getVideoFileToken({ videoId: replay.uuid }) + + const hls = replay.streamingPlaylists[0] + expect(hls.files).to.not.have.lengthOf(0) + + for (const file of hls.files) { + await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.fileUrl, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url: file.fileUrl, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ + url: file.fileUrl, + query: { videoFileToken: unrelatedFileToken }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + + for (const url of [ hls.playlistUrl, hls.segmentsSha256Url ]) { + expectStartWith(url, server.url + '/static/streaming-playlists/hls/private/') + + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + } + + before(async function () { + await server.config.enableMinimumTranscoding() + + const { uuid } = await server.videos.quickUpload({ name: 'another video' }) + unrelatedFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + + await server.config.enableLive({ + allowReplay: true, + transcoding: true, + resolutions: 'min' + }) + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: true, + permanentLive: false, + privacy: VideoPrivacy.PRIVATE + }) + normalLiveId = video.uuid + normalLive = live + } + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: true, + permanentLive: true, + privacy: VideoPrivacy.PRIVATE + }) + permanentLiveId = video.uuid + permanentLive = live + } + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: false, + permanentLive: false, + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ correctPassword ] + }) + passwordProtectedLiveId = video.uuid + passwordProtectedLive = live + } + }) + + it('Should create a private normal live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles({ live: normalLive, liveId: normalLiveId }) + }) + + it('Should create a private permanent live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles({ live: permanentLive, liveId: permanentLiveId }) + }) + + it('Should create a password protected live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles({ live: passwordProtectedLive, liveId: passwordProtectedLiveId, videoPassword: correctPassword }) + }) + + it('Should reinject video file token on permanent live', async function () { + this.timeout(240000) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: permanentLive.rtmpUrl, streamKey: permanentLive.streamKey }) + await server.live.waitUntilPublished({ videoId: permanentLiveId }) + + const video = await server.videos.getWithToken({ id: permanentLiveId }) + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) + const hls = video.streamingPlaylists[0] + + { + const query = { videoFileToken } + const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 }) + + expect(text).to.not.include(videoFileToken) + } + + { + await checkVideoFileTokenReinjection({ + server, + videoUUID: permanentLiveId, + videoFileToken, + resolutions: [ 720 ], + isLive: true + }) + } + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should have created a replay of the normal live with a private static path', async function () { + this.timeout(240000) + + await server.live.waitUntilReplacedByReplay({ videoId: normalLiveId }) + + const replay = await server.videos.getWithToken({ id: normalLiveId }) + await checkReplay(replay) + }) + + it('Should have created a replay of the permanent live with a private static path', async function () { + this.timeout(240000) + + await server.live.waitUntilWaiting({ videoId: permanentLiveId }) + await waitJobs([ server ]) + + const live = await server.videos.getWithToken({ id: permanentLiveId }) + const replayFromList = await findExternalSavedVideo(server, live) + const replay = await server.videos.getWithToken({ id: replayFromList.id }) + + await checkReplay(replay) + }) + }) + + describe('With static file right check disabled', function () { + let videoUUID: string + + before(async function () { + this.timeout(240000) + + await server.kill() + + await server.run({ + static_files: { + private_files_require_auth: false + } + }) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) + videoUUID = uuid + + await waitJobs([ server ]) + }) + + it('Should not check auth for private static files', async function () { + const video = await server.videos.getWithToken({ id: videoUUID }) + + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = video.streamingPlaylists[0] + await makeRawRequest({ url: hls.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-storyboard.ts b/packages/tests/src/api/videos/video-storyboard.ts new file mode 100644 index 000000000..7d156aa7f --- /dev/null +++ b/packages/tests/src/api/videos/video-storyboard.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { readdir } from 'fs/promises' +import { basename } from 'path' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' + +async function checkStoryboard (options: { + server: PeerTubeServer + uuid: string + tilesCount?: number + minSize?: number +}) { + const { server, uuid, tilesCount, minSize = 1000 } = options + + const { storyboards } = await server.storyboard.list({ id: uuid }) + + expect(storyboards).to.have.lengthOf(1) + + const storyboard = storyboards[0] + + expect(storyboard.spriteDuration).to.equal(1) + expect(storyboard.spriteHeight).to.equal(108) + expect(storyboard.spriteWidth).to.equal(192) + expect(storyboard.storyboardPath).to.exist + + if (tilesCount) { + expect(storyboard.totalWidth).to.equal(192 * Math.min(tilesCount, 10)) + expect(storyboard.totalHeight).to.equal(108 * Math.max((tilesCount / 10), 1)) + } + + const { body } = await makeGetRequest({ url: server.url, path: storyboard.storyboardPath, expectedStatus: HttpStatusCode.OK_200 }) + expect(body.length).to.be.above(minSize) +} + +describe('Test video storyboard', function () { + let servers: PeerTubeServer[] + + let baseUUID: string + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should generate a storyboard after upload without transcoding', async function () { + this.timeout(120000) + + // 5s video + const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_short.webm' }) + baseUUID = uuid + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid, tilesCount: 5 }) + } + }) + + it('Should generate a storyboard after upload without transcoding with a long video', async function () { + this.timeout(120000) + + // 124s video + const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_very_long_10p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid, tilesCount: 100 }) + } + }) + + it('Should generate a storyboard after upload with transcoding', async function () { + this.timeout(120000) + + await servers[0].config.enableMinimumTranscoding() + + // 5s video + const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_short.webm' }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid, tilesCount: 5 }) + } + }) + + it('Should generate a storyboard after an audio upload', async function () { + this.timeout(120000) + + // 6s audio + const attributes = { name: 'audio', fixture: 'sample.ogg' } + const { uuid } = await servers[0].videos.upload({ attributes, mode: 'legacy' }) + await waitJobs(servers) + + for (const server of servers) { + try { + await checkStoryboard({ server, uuid, tilesCount: 6, minSize: 250 }) + } catch { // FIXME: to remove after ffmpeg CI upgrade, ffmpeg CI version (4.3) generates a 7.6s length video + await checkStoryboard({ server, uuid, tilesCount: 8, minSize: 250 }) + } + } + }) + + it('Should generate a storyboard after HTTP import', async function () { + this.timeout(120000) + + if (areHttpImportTestsDisabled()) return + + // 3s video + const { video } = await servers[0].imports.importVideo({ + attributes: { + targetUrl: FIXTURE_URLS.goodVideo, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid: video.uuid, tilesCount: 3 }) + } + }) + + it('Should generate a storyboard after torrent import', async function () { + this.timeout(120000) + + if (areHttpImportTestsDisabled()) return + + // 10s video + const { video } = await servers[0].imports.importVideo({ + attributes: { + magnetUri: FIXTURE_URLS.magnet, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid: video.uuid, tilesCount: 10 }) + } + }) + + it('Should generate a storyboard after a live', async function () { + this.timeout(240000) + + await servers[0].config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' }) + + const { live, video } = await servers[0].live.quickCreate({ + saveReplay: true, + permanentLive: false, + privacy: VideoPrivacy.PUBLIC + }) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await servers[0].live.waitUntilPublished({ videoId: video.id }) + + await stopFfmpeg(ffmpegCommand) + + await servers[0].live.waitUntilReplacedByReplay({ videoId: video.id }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid: video.uuid }) + } + }) + + it('Should cleanup storyboards on video deletion', async function () { + this.timeout(60000) + + const { storyboards } = await servers[0].storyboard.list({ id: baseUUID }) + const storyboardName = basename(storyboards[0].storyboardPath) + + const listFiles = () => { + const storyboardPath = servers[0].getDirectoryPath('storyboards') + return readdir(storyboardPath) + } + + { + const storyboads = await listFiles() + expect(storyboads).to.include(storyboardName) + } + + await servers[0].videos.remove({ id: baseUUID }) + await waitJobs(servers) + + { + const storyboads = await listFiles() + expect(storyboads).to.not.include(storyboardName) + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/videos-common-filters.ts b/packages/tests/src/api/videos/videos-common-filters.ts new file mode 100644 index 000000000..9e75bd6ca --- /dev/null +++ b/packages/tests/src/api/videos/videos-common-filters.ts @@ -0,0 +1,499 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + HttpStatusCodeType, + UserRole, + Video, + VideoDetails, + VideoInclude, + VideoIncludeType, + VideoPrivacy, + VideoPrivacyType +} from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test videos filter', function () { + let servers: PeerTubeServer[] + let paths: string[] + let remotePaths: string[] + + const subscriptionVideosPath = '/api/v1/users/me/subscriptions/videos' + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + await servers[1].config.enableMinimumTranscoding() + + for (const server of servers) { + const moderator = { username: 'moderator', password: 'my super password' } + await server.users.create({ username: moderator.username, password: moderator.password, role: UserRole.MODERATOR }) + server['moderatorAccessToken'] = await server.login.getAccessToken(moderator) + + await server.videos.upload({ attributes: { name: 'public ' + server.serverNumber } }) + + { + const attributes = { name: 'unlisted ' + server.serverNumber, privacy: VideoPrivacy.UNLISTED } + await server.videos.upload({ attributes }) + } + + { + const attributes = { name: 'private ' + server.serverNumber, privacy: VideoPrivacy.PRIVATE } + await server.videos.upload({ attributes }) + } + + // Subscribing to itself + await server.subscriptions.add({ targetUri: 'root_channel@' + server.host }) + } + + await doubleFollow(servers[0], servers[1]) + + paths = [ + `/api/v1/video-channels/root_channel/videos`, + `/api/v1/accounts/root/videos`, + '/api/v1/videos', + '/api/v1/search/videos', + subscriptionVideosPath + ] + + remotePaths = [ + `/api/v1/video-channels/root_channel@${servers[1].host}/videos`, + `/api/v1/accounts/root@${servers[1].host}/videos`, + '/api/v1/videos', + '/api/v1/search/videos' + ] + }) + + describe('Check videos filters', function () { + + async function listVideos (options: { + server: PeerTubeServer + path: string + isLocal?: boolean + hasWebVideoFiles?: boolean + hasHLSFiles?: boolean + include?: VideoIncludeType + privacyOneOf?: VideoPrivacyType[] + category?: number + tagsAllOf?: string[] + token?: string + expectedStatus?: HttpStatusCodeType + excludeAlreadyWatched?: boolean + }) { + const res = await makeGetRequest({ + url: options.server.url, + path: options.path, + token: options.token ?? options.server.accessToken, + query: { + ...pick(options, [ + 'isLocal', + 'include', + 'category', + 'tagsAllOf', + 'hasWebVideoFiles', + 'hasHLSFiles', + 'privacyOneOf', + 'excludeAlreadyWatched' + ]), + + sort: 'createdAt' + }, + expectedStatus: options.expectedStatus ?? HttpStatusCode.OK_200 + }) + + return res.body.data as Video[] + } + + async function getVideosNames ( + options: { + server: PeerTubeServer + isLocal?: boolean + include?: VideoIncludeType + privacyOneOf?: VideoPrivacyType[] + token?: string + expectedStatus?: HttpStatusCodeType + skipSubscription?: boolean + excludeAlreadyWatched?: boolean + } + ) { + const { skipSubscription = false } = options + const videosResults: string[][] = [] + + for (const path of paths) { + if (skipSubscription && path === subscriptionVideosPath) continue + + const videos = await listVideos({ ...options, path }) + + videosResults.push(videos.map(v => v.name)) + } + + return videosResults + } + + it('Should display local videos', async function () { + for (const server of servers) { + const namesResults = await getVideosNames({ server, isLocal: true }) + + for (const names of namesResults) { + expect(names).to.have.lengthOf(1) + expect(names[0]).to.equal('public ' + server.serverNumber) + } + } + }) + + it('Should display local videos with hidden privacy by the admin or the moderator', async function () { + for (const server of servers) { + for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { + + const namesResults = await getVideosNames( + { + server, + token, + isLocal: true, + privacyOneOf: [ VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC, VideoPrivacy.PRIVATE ], + skipSubscription: true + } + ) + + for (const names of namesResults) { + expect(names).to.have.lengthOf(3) + + expect(names[0]).to.equal('public ' + server.serverNumber) + expect(names[1]).to.equal('unlisted ' + server.serverNumber) + expect(names[2]).to.equal('private ' + server.serverNumber) + } + } + } + }) + + it('Should display all videos by the admin or the moderator', async function () { + for (const server of servers) { + for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { + + const [ channelVideos, accountVideos, videos, searchVideos ] = await getVideosNames({ + server, + token, + privacyOneOf: [ VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC, VideoPrivacy.PRIVATE ] + }) + + expect(channelVideos).to.have.lengthOf(3) + expect(accountVideos).to.have.lengthOf(3) + + expect(videos).to.have.lengthOf(5) + expect(searchVideos).to.have.lengthOf(5) + } + } + }) + + it('Should display only remote videos', async function () { + this.timeout(120000) + + await servers[1].videos.upload({ attributes: { name: 'remote video' } }) + + await waitJobs(servers) + + const finder = (videos: Video[]) => videos.find(v => v.name === 'remote video') + + for (const path of remotePaths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.exist + } + + { + const videos = await listVideos({ server: servers[0], path, isLocal: false }) + const video = finder(videos) + expect(video).to.exist + } + + { + const videos = await listVideos({ server: servers[0], path, isLocal: true }) + const video = finder(videos) + expect(video).to.not.exist + } + } + }) + + it('Should include not published videos', async function () { + await servers[0].config.enableLive({ allowReplay: false, transcoding: false }) + await servers[0].live.create({ fields: { name: 'live video', channelId: servers[0].store.channel.id, privacy: VideoPrivacy.PUBLIC } }) + + const finder = (videos: Video[]) => videos.find(v => v.name === 'live video') + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + expect(videos[0].state).to.not.exist + expect(videos[0].waitTranscoding).to.not.exist + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.NOT_PUBLISHED_STATE }) + const video = finder(videos) + expect(video).to.exist + expect(video.state).to.exist + } + } + }) + + it('Should include blacklisted videos', async function () { + const { id } = await servers[0].videos.upload({ attributes: { name: 'blacklisted' } }) + + await servers[0].blacklist.add({ videoId: id }) + + const finder = (videos: Video[]) => videos.find(v => v.name === 'blacklisted') + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + expect(videos[0].blacklisted).to.not.exist + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.BLACKLISTED }) + const video = finder(videos) + expect(video).to.exist + expect(video.blacklisted).to.be.true + } + } + }) + + it('Should include videos from muted account', async function () { + const finder = (videos: Video[]) => videos.find(v => v.name === 'remote video') + + await servers[0].blocklist.addToServerBlocklist({ account: 'root@' + servers[1].host }) + + for (const path of remotePaths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + + // Some paths won't have videos + if (videos[0]) { + expect(videos[0].blockedOwner).to.not.exist + expect(videos[0].blockedServer).to.not.exist + } + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.BLOCKED_OWNER }) + + const video = finder(videos) + expect(video).to.exist + expect(video.blockedServer).to.be.false + expect(video.blockedOwner).to.be.true + } + } + + await servers[0].blocklist.removeFromServerBlocklist({ account: 'root@' + servers[1].host }) + }) + + it('Should include videos from muted server', async function () { + const finder = (videos: Video[]) => videos.find(v => v.name === 'remote video') + + await servers[0].blocklist.addToServerBlocklist({ server: servers[1].host }) + + for (const path of remotePaths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + + // Some paths won't have videos + if (videos[0]) { + expect(videos[0].blockedOwner).to.not.exist + expect(videos[0].blockedServer).to.not.exist + } + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.BLOCKED_OWNER }) + const video = finder(videos) + expect(video).to.exist + expect(video.blockedServer).to.be.true + expect(video.blockedOwner).to.be.false + } + } + + await servers[0].blocklist.removeFromServerBlocklist({ server: servers[1].host }) + }) + + it('Should include video files', async function () { + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path }) + + for (const video of videos) { + const videoWithFiles = video as VideoDetails + + expect(videoWithFiles.files).to.not.exist + expect(videoWithFiles.streamingPlaylists).to.not.exist + } + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.FILES }) + + for (const video of videos) { + const videoWithFiles = video as VideoDetails + + expect(videoWithFiles.files).to.exist + expect(videoWithFiles.files).to.have.length.at.least(1) + } + } + } + }) + + it('Should filter by tags and category', async function () { + await servers[0].videos.upload({ attributes: { name: 'tag filter', tags: [ 'tag1', 'tag2' ] } }) + await servers[0].videos.upload({ attributes: { name: 'tag filter with category', tags: [ 'tag3' ], category: 4 } }) + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path, tagsAllOf: [ 'tag1', 'tag2' ] }) + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('tag filter') + } + + { + const videos = await listVideos({ server: servers[0], path, tagsAllOf: [ 'tag1', 'tag3' ] }) + expect(videos).to.have.lengthOf(0) + } + + { + const { data, total } = await servers[0].videos.list({ tagsAllOf: [ 'tag3' ], categoryOneOf: [ 4 ] }) + expect(total).to.equal(1) + expect(data[0].name).to.equal('tag filter with category') + } + + { + const { total } = await servers[0].videos.list({ tagsAllOf: [ 'tag4' ], categoryOneOf: [ 4 ] }) + expect(total).to.equal(0) + } + } + }) + + it('Should filter by HLS or Web Video files', async function () { + this.timeout(360000) + + const finderFactory = (name: string) => (videos: Video[]) => videos.some(v => v.name === name) + + await servers[0].config.enableTranscoding({ hls: false, webVideo: true }) + await servers[0].videos.upload({ attributes: { name: 'web video' } }) + const hasWebVideo = finderFactory('web video') + + await waitJobs(servers) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: false }) + await servers[0].videos.upload({ attributes: { name: 'hls video' } }) + const hasHLS = finderFactory('hls video') + + await waitJobs(servers) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + await servers[0].videos.upload({ attributes: { name: 'hls and web video' } }) + const hasBoth = finderFactory('hls and web video') + + await waitJobs(servers) + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path, hasWebVideoFiles: true }) + + expect(hasWebVideo(videos)).to.be.true + expect(hasHLS(videos)).to.be.false + expect(hasBoth(videos)).to.be.true + } + + { + const videos = await listVideos({ server: servers[0], path, hasWebVideoFiles: false }) + + expect(hasWebVideo(videos)).to.be.false + expect(hasHLS(videos)).to.be.true + expect(hasBoth(videos)).to.be.false + } + + { + const videos = await listVideos({ server: servers[0], path, hasHLSFiles: true }) + + expect(hasWebVideo(videos)).to.be.false + expect(hasHLS(videos)).to.be.true + expect(hasBoth(videos)).to.be.true + } + + { + const videos = await listVideos({ server: servers[0], path, hasHLSFiles: false }) + + expect(hasWebVideo(videos)).to.be.true + expect(hasHLS(videos)).to.be.false + expect(hasBoth(videos)).to.be.false + } + + { + const videos = await listVideos({ server: servers[0], path, hasHLSFiles: false, hasWebVideoFiles: false }) + + expect(hasWebVideo(videos)).to.be.false + expect(hasHLS(videos)).to.be.false + expect(hasBoth(videos)).to.be.false + } + + { + const videos = await listVideos({ server: servers[0], path, hasHLSFiles: true, hasWebVideoFiles: true }) + + expect(hasWebVideo(videos)).to.be.false + expect(hasHLS(videos)).to.be.false + expect(hasBoth(videos)).to.be.true + } + } + }) + + it('Should filter already watched videos by the user', async function () { + const { id } = await servers[0].videos.upload({ attributes: { name: 'video for history' } }) + + for (const path of paths) { + const videos = await listVideos({ server: servers[0], path, isLocal: true, excludeAlreadyWatched: true }) + const foundVideo = videos.find(video => video.id === id) + + expect(foundVideo).to.not.be.undefined + } + await servers[0].views.view({ id, currentTime: 1, token: servers[0].accessToken }) + + for (const path of paths) { + const videos = await listVideos({ server: servers[0], path, excludeAlreadyWatched: true }) + const foundVideo = videos.find(video => video.id === id) + + expect(foundVideo).to.be.undefined + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/videos-history.ts b/packages/tests/src/api/videos/videos-history.ts new file mode 100644 index 000000000..75c0fcebd --- /dev/null +++ b/packages/tests/src/api/videos/videos-history.ts @@ -0,0 +1,230 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { Video } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + killallServers, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test videos history', function () { + let server: PeerTubeServer = null + let video1Id: number + let video1UUID: string + let video2UUID: string + let video3UUID: string + let video3WatchedDate: Date + let userAccessToken: string + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + // 10 seconds long + const fixture = 'video_short1.webm' + + { + const { id, uuid } = await server.videos.upload({ attributes: { name: 'video 1', fixture } }) + video1UUID = uuid + video1Id = id + } + + { + const { uuid } = await server.videos.upload({ attributes: { name: 'video 2', fixture } }) + video2UUID = uuid + } + + { + const { uuid } = await server.videos.upload({ attributes: { name: 'video 3', fixture } }) + video3UUID = uuid + } + + userAccessToken = await server.users.generateUserAndToken('user_1') + }) + + it('Should get videos, without watching history', async function () { + const { data } = await server.videos.listWithToken() + + for (const video of data) { + const videoDetails = await server.videos.getWithToken({ id: video.id }) + + expect(video.userHistory).to.be.undefined + expect(videoDetails.userHistory).to.be.undefined + } + }) + + it('Should watch the first and second video', async function () { + await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 }) + await server.views.view({ id: video1UUID, token: server.accessToken, currentTime: 3 }) + }) + + it('Should return the correct history when listing, searching and getting videos', async function () { + const videosOfVideos: Video[][] = [] + + { + const { data } = await server.videos.listWithToken() + videosOfVideos.push(data) + } + + { + const body = await server.search.searchVideos({ token: server.accessToken, search: 'video' }) + videosOfVideos.push(body.data) + } + + for (const videos of videosOfVideos) { + const video1 = videos.find(v => v.uuid === video1UUID) + const video2 = videos.find(v => v.uuid === video2UUID) + const video3 = videos.find(v => v.uuid === video3UUID) + + expect(video1.userHistory).to.not.be.undefined + expect(video1.userHistory.currentTime).to.equal(3) + + expect(video2.userHistory).to.not.be.undefined + expect(video2.userHistory.currentTime).to.equal(8) + + expect(video3.userHistory).to.be.undefined + } + + { + const videoDetails = await server.videos.getWithToken({ id: video1UUID }) + + expect(videoDetails.userHistory).to.not.be.undefined + expect(videoDetails.userHistory.currentTime).to.equal(3) + } + + { + const videoDetails = await server.videos.getWithToken({ id: video2UUID }) + + expect(videoDetails.userHistory).to.not.be.undefined + expect(videoDetails.userHistory.currentTime).to.equal(8) + } + + { + const videoDetails = await server.videos.getWithToken({ id: video3UUID }) + + expect(videoDetails.userHistory).to.be.undefined + } + }) + + it('Should have these videos when listing my history', async function () { + video3WatchedDate = new Date() + await server.views.view({ id: video3UUID, token: server.accessToken, currentTime: 2 }) + + const body = await server.history.list() + + expect(body.total).to.equal(3) + + const videos = body.data + expect(videos[0].name).to.equal('video 3') + expect(videos[1].name).to.equal('video 1') + expect(videos[2].name).to.equal('video 2') + }) + + it('Should not have videos history on another user', async function () { + const body = await server.history.list({ token: userAccessToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should be able to search through videos in my history', async function () { + const body = await server.history.list({ search: '2' }) + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos[0].name).to.equal('video 2') + }) + + it('Should clear my history', async function () { + await server.history.removeAll({ beforeDate: video3WatchedDate.toISOString() }) + }) + + it('Should have my history cleared', async function () { + const body = await server.history.list() + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos[0].name).to.equal('video 3') + }) + + it('Should disable videos history', async function () { + await server.users.updateMe({ + videosHistoryEnabled: false + }) + + await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 }) + + const { data } = await server.history.list() + expect(data[0].name).to.not.equal('video 2') + }) + + it('Should re-enable videos history', async function () { + await server.users.updateMe({ + videosHistoryEnabled: true + }) + + await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 }) + + const { data } = await server.history.list() + expect(data[0].name).to.equal('video 2') + }) + + it('Should not clean old history', async function () { + this.timeout(50000) + + await killallServers([ server ]) + + await server.run({ history: { videos: { max_age: '10 days' } } }) + + await wait(6000) + + // Should still have history + + const body = await server.history.list() + expect(body.total).to.equal(2) + }) + + it('Should clean old history', async function () { + this.timeout(50000) + + await killallServers([ server ]) + + await server.run({ history: { videos: { max_age: '5 seconds' } } }) + + await wait(6000) + + const body = await server.history.list() + expect(body.total).to.equal(0) + }) + + it('Should delete a specific history element', async function () { + { + await server.views.view({ id: video1UUID, token: server.accessToken, currentTime: 4 }) + await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 }) + } + + { + const body = await server.history.list() + expect(body.total).to.equal(2) + } + + { + await server.history.removeElement({ videoId: video1Id }) + + const body = await server.history.list() + expect(body.total).to.equal(1) + expect(body.data[0].uuid).to.equal(video2UUID) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/videos-overview.ts b/packages/tests/src/api/videos/videos-overview.ts new file mode 100644 index 000000000..7d74d6db2 --- /dev/null +++ b/packages/tests/src/api/videos/videos-overview.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { VideosOverview } from '@peertube/peertube-models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' + +describe('Test a videos overview', function () { + let server: PeerTubeServer = null + + function testOverviewCount (overview: VideosOverview, expected: number) { + expect(overview.tags).to.have.lengthOf(expected) + expect(overview.categories).to.have.lengthOf(expected) + expect(overview.channels).to.have.lengthOf(expected) + } + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + }) + + it('Should send empty overview', async function () { + const body = await server.overviews.getVideos({ page: 1 }) + + testOverviewCount(body, 0) + }) + + it('Should upload 5 videos in a specific category, tag and channel but not include them in overview', async function () { + this.timeout(60000) + + await wait(3000) + + await server.videos.upload({ + attributes: { + name: 'video 0', + category: 3, + tags: [ 'coucou1', 'coucou2' ] + } + }) + + const body = await server.overviews.getVideos({ page: 1 }) + + testOverviewCount(body, 0) + }) + + it('Should upload another video and include all videos in the overview', async function () { + this.timeout(120000) + + { + for (let i = 1; i < 6; i++) { + await server.videos.upload({ + attributes: { + name: 'video ' + i, + category: 3, + tags: [ 'coucou1', 'coucou2' ] + } + }) + } + + await wait(3000) + } + + { + const body = await server.overviews.getVideos({ page: 1 }) + + testOverviewCount(body, 1) + } + + { + const overview = await server.overviews.getVideos({ page: 2 }) + + expect(overview.tags).to.have.lengthOf(1) + expect(overview.categories).to.have.lengthOf(0) + expect(overview.channels).to.have.lengthOf(0) + } + }) + + it('Should have the correct overview', async function () { + const overview1 = await server.overviews.getVideos({ page: 1 }) + const overview2 = await server.overviews.getVideos({ page: 2 }) + + for (const arr of [ overview1.tags, overview1.categories, overview1.channels, overview2.tags ]) { + expect(arr).to.have.lengthOf(1) + + const obj = arr[0] + + expect(obj.videos).to.have.lengthOf(6) + expect(obj.videos[0].name).to.equal('video 5') + expect(obj.videos[1].name).to.equal('video 4') + expect(obj.videos[2].name).to.equal('video 3') + expect(obj.videos[3].name).to.equal('video 2') + expect(obj.videos[4].name).to.equal('video 1') + expect(obj.videos[5].name).to.equal('video 0') + } + + const tags = [ overview1.tags[0].tag, overview2.tags[0].tag ] + expect(tags.find(t => t === 'coucou1')).to.not.be.undefined + expect(tags.find(t => t === 'coucou2')).to.not.be.undefined + + expect(overview1.categories[0].category.id).to.equal(3) + + expect(overview1.channels[0].channel.name).to.equal('root_channel') + }) + + it('Should hide muted accounts', async function () { + const token = await server.users.generateUserAndToken('choco') + + await server.blocklist.addToMyBlocklist({ token, account: 'root@' + server.host }) + + { + const body = await server.overviews.getVideos({ page: 1 }) + + testOverviewCount(body, 1) + } + + { + const body = await server.overviews.getVideos({ page: 1, token }) + + testOverviewCount(body, 0) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/views/index.ts b/packages/tests/src/api/views/index.ts new file mode 100644 index 000000000..2b7334d1a --- /dev/null +++ b/packages/tests/src/api/views/index.ts @@ -0,0 +1,5 @@ +export * from './video-views-counter.js' +export * from './video-views-overall-stats.js' +export * from './video-views-retention-stats.js' +export * from './video-views-timeserie-stats.js' +export * from './videos-views-cleaner.js' diff --git a/packages/tests/src/api/views/video-views-counter.ts b/packages/tests/src/api/views/video-views-counter.ts new file mode 100644 index 000000000..d9afb0f18 --- /dev/null +++ b/packages/tests/src/api/views/video-views-counter.ts @@ -0,0 +1,153 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { FfmpegCommand } from 'fluent-ffmpeg' +import { prepareViewsServers, prepareViewsVideos, processViewsBuffer } from '@tests/shared/views.js' +import { wait } from '@peertube/peertube-core-utils' +import { cleanupTests, PeerTubeServer, stopFfmpeg, waitJobs } from '@peertube/peertube-server-commands' + +describe('Test video views/viewers counters', function () { + let servers: PeerTubeServer[] + + async function checkCounter (field: 'views' | 'viewers', id: string, expected: number) { + for (const server of servers) { + const video = await server.videos.get({ id }) + + const messageSuffix = video.isLive + ? 'live video' + : 'vod video' + + expect(video[field]).to.equal(expected, `${field} not valid on server ${server.serverNumber} for ${messageSuffix} ${video.uuid}`) + } + } + + before(async function () { + this.timeout(120000) + + servers = await prepareViewsServers() + }) + + describe('Test views counter on VOD', function () { + let videoUUID: string + + before(async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) + videoUUID = uuid + + await waitJobs(servers) + }) + + it('Should not view a video if watch time is below the threshold', async function () { + await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 2 ] }) + await processViewsBuffer(servers) + + await checkCounter('views', videoUUID, 0) + }) + + it('Should view a video if watch time is above the threshold', async function () { + await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 4 ] }) + await processViewsBuffer(servers) + + await checkCounter('views', videoUUID, 1) + }) + + it('Should not view again this video with the same IP', async function () { + await servers[0].views.simulateViewer({ id: videoUUID, xForwardedFor: '0.0.0.1,127.0.0.1', currentTimes: [ 1, 4 ] }) + await servers[0].views.simulateViewer({ id: videoUUID, xForwardedFor: '0.0.0.1,127.0.0.1', currentTimes: [ 1, 4 ] }) + await processViewsBuffer(servers) + + await checkCounter('views', videoUUID, 2) + }) + + it('Should view the video from server 2 and send the event', async function () { + await servers[1].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 4 ] }) + await waitJobs(servers) + await processViewsBuffer(servers) + + await checkCounter('views', videoUUID, 3) + }) + }) + + describe('Test views and viewers counters on live and VOD', function () { + let liveVideoId: string + let vodVideoId: string + let command: FfmpegCommand + + before(async function () { + this.timeout(240000); + + ({ vodVideoId, liveVideoId, ffmpegCommand: command } = await prepareViewsVideos({ servers, live: true, vod: true })) + }) + + it('Should display no views and viewers', async function () { + await checkCounter('views', liveVideoId, 0) + await checkCounter('viewers', liveVideoId, 0) + + await checkCounter('views', vodVideoId, 0) + await checkCounter('viewers', vodVideoId, 0) + }) + + it('Should view twice and display 1 view/viewer', async function () { + this.timeout(30000) + + await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] }) + await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] }) + await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] }) + await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] }) + + await waitJobs(servers) + await checkCounter('viewers', liveVideoId, 1) + await checkCounter('viewers', vodVideoId, 1) + + await processViewsBuffer(servers) + + await checkCounter('views', liveVideoId, 1) + await checkCounter('views', vodVideoId, 1) + }) + + it('Should wait and display 0 viewers but still have 1 view', async function () { + this.timeout(30000) + + await wait(12000) + await waitJobs(servers) + + await checkCounter('views', liveVideoId, 1) + await checkCounter('viewers', liveVideoId, 0) + + await checkCounter('views', vodVideoId, 1) + await checkCounter('viewers', vodVideoId, 0) + }) + + it('Should view on a remote and on local and display 2 viewers and 3 views', async function () { + this.timeout(30000) + + await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] }) + await servers[1].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] }) + await servers[1].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] }) + + await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] }) + await servers[1].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] }) + await servers[1].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] }) + + await waitJobs(servers) + + await checkCounter('viewers', liveVideoId, 2) + await checkCounter('viewers', vodVideoId, 2) + + await processViewsBuffer(servers) + + await checkCounter('views', liveVideoId, 3) + await checkCounter('views', vodVideoId, 3) + }) + + after(async function () { + await stopFfmpeg(command) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/views/video-views-overall-stats.ts b/packages/tests/src/api/views/video-views-overall-stats.ts new file mode 100644 index 000000000..6ea0da2d9 --- /dev/null +++ b/packages/tests/src/api/views/video-views-overall-stats.ts @@ -0,0 +1,368 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { FfmpegCommand } from 'fluent-ffmpeg' +import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@tests/shared/views.js' +import { cleanupTests, PeerTubeServer, stopFfmpeg, waitJobs } from '@peertube/peertube-server-commands' +import { wait } from '@peertube/peertube-core-utils' +import { VideoStatsOverall } from '@peertube/peertube-models' + +/** + * + * Simulate 5 sections of viewers + * * user0 started and ended before start date + * * user1 started before start date and ended in the interval + * * user2 started started in the interval and ended after end date + * * user3 started and ended in the interval + * * user4 started and ended after end date + */ +async function simulateComplexViewers (servers: PeerTubeServer[], videoUUID: string) { + const user0 = '8.8.8.8,127.0.0.1' + const user1 = '8.8.8.8,127.0.0.1' + const user2 = '8.8.8.9,127.0.0.1' + const user3 = '8.8.8.10,127.0.0.1' + const user4 = '8.8.8.11,127.0.0.1' + + await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user0 }) // User 0 starts + await wait(500) + + await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user1 }) // User 1 starts + await servers[0].views.view({ id: videoUUID, currentTime: 2, xForwardedFor: user0 }) // User 0 ends + await wait(500) + + const startDate = new Date().toISOString() + await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user2 }) // User 2 starts + await wait(500) + + await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user3 }) // User 3 starts + await wait(500) + + await servers[0].views.view({ id: videoUUID, currentTime: 4, xForwardedFor: user1 }) // User 1 ends + await wait(500) + + await servers[0].views.view({ id: videoUUID, currentTime: 3, xForwardedFor: user3 }) // User 3 ends + await wait(500) + + const endDate = new Date().toISOString() + await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user4 }) // User 4 starts + await servers[0].views.view({ id: videoUUID, currentTime: 5, xForwardedFor: user2 }) // User 2 ends + await wait(500) + + await servers[0].views.view({ id: videoUUID, currentTime: 1, xForwardedFor: user4 }) // User 4 ends + + await processViewersStats(servers) + + return { startDate, endDate } +} + +describe('Test views overall stats', function () { + let servers: PeerTubeServer[] + + before(async function () { + this.timeout(120000) + + servers = await prepareViewsServers() + }) + + describe('Test watch time stats of local videos on live and VOD', function () { + let vodVideoId: string + let liveVideoId: string + let command: FfmpegCommand + + before(async function () { + this.timeout(240000); + + ({ vodVideoId, liveVideoId, ffmpegCommand: command } = await prepareViewsVideos({ servers, live: true, vod: true })) + }) + + it('Should display overall stats of a video with no viewers', async function () { + for (const videoId of [ liveVideoId, vodVideoId ]) { + const stats = await servers[0].videoStats.getOverallStats({ videoId }) + const video = await servers[0].videos.get({ id: videoId }) + + expect(video.views).to.equal(0) + expect(stats.averageWatchTime).to.equal(0) + expect(stats.totalWatchTime).to.equal(0) + expect(stats.totalViewers).to.equal(0) + } + }) + + it('Should display overall stats with 1 viewer below the watch time limit', async function () { + this.timeout(60000) + + for (const videoId of [ liveVideoId, vodVideoId ]) { + await servers[0].views.simulateViewer({ id: videoId, currentTimes: [ 0, 1 ] }) + } + + await processViewersStats(servers) + + for (const videoId of [ liveVideoId, vodVideoId ]) { + const stats = await servers[0].videoStats.getOverallStats({ videoId }) + const video = await servers[0].videos.get({ id: videoId }) + + expect(video.views).to.equal(0) + expect(stats.averageWatchTime).to.equal(1) + expect(stats.totalWatchTime).to.equal(1) + expect(stats.totalViewers).to.equal(1) + } + }) + + it('Should display overall stats with 2 viewers', async function () { + this.timeout(60000) + + { + await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 3 ] }) + await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35, 40 ] }) + + await processViewersStats(servers) + + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId }) + const video = await servers[0].videos.get({ id: vodVideoId }) + + expect(video.views).to.equal(1) + expect(stats.averageWatchTime).to.equal(2) + expect(stats.totalWatchTime).to.equal(4) + expect(stats.totalViewers).to.equal(2) + } + + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId }) + const video = await servers[0].videos.get({ id: liveVideoId }) + + expect(video.views).to.equal(1) + expect(stats.averageWatchTime).to.equal(21) + expect(stats.totalWatchTime).to.equal(41) + expect(stats.totalViewers).to.equal(2) + } + } + }) + + it('Should display overall stats with a remote viewer below the watch time limit', async function () { + this.timeout(60000) + + for (const videoId of [ liveVideoId, vodVideoId ]) { + await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 2 ] }) + } + + await processViewersStats(servers) + + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId }) + const video = await servers[0].videos.get({ id: vodVideoId }) + + expect(video.views).to.equal(1) + expect(stats.averageWatchTime).to.equal(2) + expect(stats.totalWatchTime).to.equal(6) + expect(stats.totalViewers).to.equal(3) + } + + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId }) + const video = await servers[0].videos.get({ id: liveVideoId }) + + expect(video.views).to.equal(1) + expect(stats.averageWatchTime).to.equal(14) + expect(stats.totalWatchTime).to.equal(43) + expect(stats.totalViewers).to.equal(3) + } + }) + + it('Should display overall stats with a remote viewer above the watch time limit', async function () { + this.timeout(60000) + + await servers[1].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] }) + await servers[1].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 45 ] }) + await processViewersStats(servers) + + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId }) + const video = await servers[0].videos.get({ id: vodVideoId }) + + expect(video.views).to.equal(2) + expect(stats.averageWatchTime).to.equal(3) + expect(stats.totalWatchTime).to.equal(11) + expect(stats.totalViewers).to.equal(4) + } + + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId }) + const video = await servers[0].videos.get({ id: liveVideoId }) + + expect(video.views).to.equal(2) + expect(stats.averageWatchTime).to.equal(22) + expect(stats.totalWatchTime).to.equal(88) + expect(stats.totalViewers).to.equal(4) + } + }) + + it('Should filter overall stats by date', async function () { + this.timeout(60000) + + const beforeView = new Date() + + await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 3 ] }) + await processViewersStats(servers) + + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId, startDate: beforeView.toISOString() }) + expect(stats.averageWatchTime).to.equal(3) + expect(stats.totalWatchTime).to.equal(3) + expect(stats.totalViewers).to.equal(1) + } + + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId, endDate: beforeView.toISOString() }) + expect(stats.averageWatchTime).to.equal(22) + expect(stats.totalWatchTime).to.equal(88) + expect(stats.totalViewers).to.equal(4) + } + }) + + after(async function () { + await stopFfmpeg(command) + }) + }) + + describe('Test watchers peak stats of local videos on VOD', function () { + let videoUUID: string + let before2Watchers: Date + + before(async function () { + this.timeout(240000); + + ({ vodVideoId: videoUUID } = await prepareViewsVideos({ servers, live: true, vod: true })) + }) + + it('Should not have watchers peak', async function () { + const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID }) + + expect(stats.viewersPeak).to.equal(0) + expect(stats.viewersPeakDate).to.be.null + }) + + it('Should have watcher peak with 1 watcher', async function () { + this.timeout(60000) + + const before = new Date() + await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 0, 2 ] }) + const after = new Date() + + await processViewersStats(servers) + + const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID }) + + expect(stats.viewersPeak).to.equal(1) + expect(new Date(stats.viewersPeakDate)).to.be.above(before).and.below(after) + }) + + it('Should have watcher peak with 2 watchers', async function () { + this.timeout(60000) + + before2Watchers = new Date() + await servers[0].views.view({ id: videoUUID, currentTime: 0 }) + await servers[1].views.view({ id: videoUUID, currentTime: 0 }) + await servers[0].views.view({ id: videoUUID, currentTime: 2 }) + await servers[1].views.view({ id: videoUUID, currentTime: 2 }) + const after = new Date() + + await processViewersStats(servers) + + const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID }) + + expect(stats.viewersPeak).to.equal(2) + expect(new Date(stats.viewersPeakDate)).to.be.above(before2Watchers).and.below(after) + }) + + it('Should filter peak viewers stats by date', async function () { + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate: new Date().toISOString() }) + expect(stats.viewersPeak).to.equal(0) + expect(stats.viewersPeakDate).to.not.exist + } + + { + const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, endDate: before2Watchers.toISOString() }) + expect(stats.viewersPeak).to.equal(1) + expect(new Date(stats.viewersPeakDate)).to.be.below(before2Watchers) + } + }) + + it('Should complex filter peak viewers by date', async function () { + this.timeout(60000) + + const { startDate, endDate } = await simulateComplexViewers(servers, videoUUID) + + const expectCorrect = (stats: VideoStatsOverall) => { + expect(stats.viewersPeak).to.equal(3) + expect(new Date(stats.viewersPeakDate)).to.be.above(new Date(startDate)).and.below(new Date(endDate)) + } + + expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate, endDate })) + expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate })) + expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, endDate })) + expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID })) + }) + }) + + describe('Test countries', function () { + let videoUUID: string + + it('Should not report countries if geoip is disabled', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) + await waitJobs(servers) + + await servers[1].views.view({ id: uuid, xForwardedFor: '8.8.8.8,127.0.0.1', currentTime: 1 }) + + await processViewersStats(servers) + + const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid }) + expect(stats.countries).to.have.lengthOf(0) + }) + + it('Should report countries if geoip is enabled', async function () { + this.timeout(240000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) + videoUUID = uuid + await waitJobs(servers) + + await Promise.all([ + servers[0].kill(), + servers[1].kill() + ]) + + const config = { geo_ip: { enabled: true } } + await Promise.all([ + servers[0].run(config), + servers[1].run(config) + ]) + + await servers[0].views.view({ id: uuid, xForwardedFor: '8.8.8.8,127.0.0.1', currentTime: 1 }) + await servers[1].views.view({ id: uuid, xForwardedFor: '8.8.8.4,127.0.0.1', currentTime: 3 }) + await servers[1].views.view({ id: uuid, xForwardedFor: '80.67.169.12,127.0.0.1', currentTime: 2 }) + + await processViewersStats(servers) + + const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid }) + expect(stats.countries).to.have.lengthOf(2) + + expect(stats.countries[0].isoCode).to.equal('US') + expect(stats.countries[0].viewers).to.equal(2) + + expect(stats.countries[1].isoCode).to.equal('FR') + expect(stats.countries[1].viewers).to.equal(1) + }) + + it('Should filter countries stats by date', async function () { + const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate: new Date().toISOString() }) + expect(stats.countries).to.have.lengthOf(0) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/views/video-views-retention-stats.ts b/packages/tests/src/api/views/video-views-retention-stats.ts new file mode 100644 index 000000000..4cd0c7da9 --- /dev/null +++ b/packages/tests/src/api/views/video-views-retention-stats.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@tests/shared/views.js' +import { cleanupTests, PeerTubeServer } from '@peertube/peertube-server-commands' + +describe('Test views retention stats', function () { + let servers: PeerTubeServer[] + + before(async function () { + this.timeout(120000) + + servers = await prepareViewsServers() + }) + + describe('Test retention stats on VOD', function () { + let vodVideoId: string + + before(async function () { + this.timeout(240000); + + ({ vodVideoId } = await prepareViewsVideos({ servers, live: false, vod: true })) + }) + + it('Should display empty retention', async function () { + const { data } = await servers[0].videoStats.getRetentionStats({ videoId: vodVideoId }) + expect(data).to.have.lengthOf(6) + + for (let i = 0; i < 6; i++) { + expect(data[i].second).to.equal(i) + expect(data[i].retentionPercent).to.equal(0) + } + }) + + it('Should display appropriate retention metrics', async function () { + await servers[0].views.simulateViewer({ xForwardedFor: '127.0.0.2,127.0.0.1', id: vodVideoId, currentTimes: [ 0, 1 ] }) + await servers[0].views.simulateViewer({ xForwardedFor: '127.0.0.3,127.0.0.1', id: vodVideoId, currentTimes: [ 1, 3 ] }) + await servers[1].views.simulateViewer({ xForwardedFor: '127.0.0.2,127.0.0.1', id: vodVideoId, currentTimes: [ 4 ] }) + await servers[1].views.simulateViewer({ xForwardedFor: '127.0.0.3,127.0.0.1', id: vodVideoId, currentTimes: [ 0, 1 ] }) + + await processViewersStats(servers) + + const { data } = await servers[0].videoStats.getRetentionStats({ videoId: vodVideoId }) + expect(data).to.have.lengthOf(6) + + expect(data.map(d => d.retentionPercent)).to.deep.equal([ 50, 75, 25, 25, 25, 0 ]) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/views/video-views-timeserie-stats.ts b/packages/tests/src/api/views/video-views-timeserie-stats.ts new file mode 100644 index 000000000..44fccb644 --- /dev/null +++ b/packages/tests/src/api/views/video-views-timeserie-stats.ts @@ -0,0 +1,253 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { FfmpegCommand } from 'fluent-ffmpeg' +import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@tests/shared/views.js' +import { VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@peertube/peertube-models' +import { cleanupTests, PeerTubeServer, stopFfmpeg } from '@peertube/peertube-server-commands' + +function buildOneMonthAgo () { + const monthAgo = new Date() + monthAgo.setHours(0, 0, 0, 0) + + monthAgo.setDate(monthAgo.getDate() - 29) + + return monthAgo +} + +describe('Test views timeserie stats', function () { + const availableMetrics: VideoStatsTimeserieMetric[] = [ 'viewers' ] + + let servers: PeerTubeServer[] + + before(async function () { + this.timeout(120000) + + servers = await prepareViewsServers() + }) + + describe('Common metric tests', function () { + let vodVideoId: string + + before(async function () { + this.timeout(240000); + + ({ vodVideoId } = await prepareViewsVideos({ servers, live: false, vod: true })) + }) + + it('Should display empty metric stats', async function () { + for (const metric of availableMetrics) { + const { data } = await servers[0].videoStats.getTimeserieStats({ videoId: vodVideoId, metric }) + + expect(data).to.have.length.at.least(1) + + for (const d of data) { + expect(d.value).to.equal(0) + } + } + }) + }) + + describe('Test viewer and watch time metrics on live and VOD', function () { + let vodVideoId: string + let liveVideoId: string + let command: FfmpegCommand + + function expectTodayLastValue (result: VideoStatsTimeserie, lastValue?: number) { + const { data } = result + + const last = data[data.length - 1] + const today = new Date().getDate() + expect(new Date(last.date).getDate()).to.equal(today) + + if (lastValue) expect(last.value).to.equal(lastValue) + } + + function expectTimeserieData (result: VideoStatsTimeserie, lastValue: number) { + const { data } = result + expect(data).to.have.length.at.least(25) + + expectTodayLastValue(result, lastValue) + + for (let i = 0; i < data.length - 2; i++) { + expect(data[i].value).to.equal(0) + } + } + + function expectInterval (result: VideoStatsTimeserie, intervalMs: number) { + const first = result.data[0] + const second = result.data[1] + expect(new Date(second.date).getTime() - new Date(first.date).getTime()).to.equal(intervalMs) + } + + before(async function () { + this.timeout(240000); + + ({ vodVideoId, liveVideoId, ffmpegCommand: command } = await prepareViewsVideos({ servers, live: true, vod: true })) + }) + + it('Should display appropriate viewers metrics', async function () { + for (const videoId of [ vodVideoId, liveVideoId ]) { + await servers[0].views.simulateViewer({ id: videoId, currentTimes: [ 0, 3 ] }) + await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 5 ] }) + } + + await processViewersStats(servers) + + for (const videoId of [ vodVideoId, liveVideoId ]) { + const result = await servers[0].videoStats.getTimeserieStats({ + videoId, + startDate: buildOneMonthAgo(), + endDate: new Date(), + metric: 'viewers' + }) + expectTimeserieData(result, 2) + } + }) + + it('Should display appropriate watch time metrics', async function () { + for (const videoId of [ vodVideoId, liveVideoId ]) { + const result = await servers[0].videoStats.getTimeserieStats({ + videoId, + startDate: buildOneMonthAgo(), + endDate: new Date(), + metric: 'aggregateWatchTime' + }) + expectTimeserieData(result, 8) + + await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 1 ] }) + } + + await processViewersStats(servers) + + for (const videoId of [ vodVideoId, liveVideoId ]) { + const result = await servers[0].videoStats.getTimeserieStats({ + videoId, + startDate: buildOneMonthAgo(), + endDate: new Date(), + metric: 'aggregateWatchTime' + }) + expectTimeserieData(result, 9) + } + }) + + it('Should use a custom start/end date', async function () { + const now = new Date() + const twentyDaysAgo = new Date() + twentyDaysAgo.setDate(twentyDaysAgo.getDate() - 19) + + const result = await servers[0].videoStats.getTimeserieStats({ + videoId: vodVideoId, + metric: 'aggregateWatchTime', + startDate: twentyDaysAgo, + endDate: now + }) + + expect(result.groupInterval).to.equal('1 day') + expect(result.data).to.have.lengthOf(20) + + const first = result.data[0] + expect(new Date(first.date).toLocaleDateString()).to.equal(twentyDaysAgo.toLocaleDateString()) + + expectInterval(result, 24 * 3600 * 1000) + expectTodayLastValue(result, 9) + }) + + it('Should automatically group by months', async function () { + const now = new Date() + const heightYearsAgo = new Date() + heightYearsAgo.setFullYear(heightYearsAgo.getFullYear() - 7) + + const result = await servers[0].videoStats.getTimeserieStats({ + videoId: vodVideoId, + metric: 'aggregateWatchTime', + startDate: heightYearsAgo, + endDate: now + }) + + expect(result.groupInterval).to.equal('6 months') + expect(result.data).to.have.length.above(10).and.below(200) + }) + + it('Should automatically group by days', async function () { + const now = new Date() + const threeMonthsAgo = new Date() + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3) + + const result = await servers[0].videoStats.getTimeserieStats({ + videoId: vodVideoId, + metric: 'aggregateWatchTime', + startDate: threeMonthsAgo, + endDate: now + }) + + expect(result.groupInterval).to.equal('2 days') + expect(result.data).to.have.length.above(10).and.below(200) + }) + + it('Should automatically group by hours', async function () { + const now = new Date() + const twoDaysAgo = new Date() + twoDaysAgo.setDate(twoDaysAgo.getDate() - 1) + + const result = await servers[0].videoStats.getTimeserieStats({ + videoId: vodVideoId, + metric: 'aggregateWatchTime', + startDate: twoDaysAgo, + endDate: now + }) + + expect(result.groupInterval).to.equal('1 hour') + expect(result.data).to.have.length.above(24).and.below(50) + + expectInterval(result, 3600 * 1000) + expectTodayLastValue(result, 9) + }) + + it('Should automatically group by ten minutes', async function () { + const now = new Date() + const twoHoursAgo = new Date() + twoHoursAgo.setHours(twoHoursAgo.getHours() - 4) + + const result = await servers[0].videoStats.getTimeserieStats({ + videoId: vodVideoId, + metric: 'aggregateWatchTime', + startDate: twoHoursAgo, + endDate: now + }) + + expect(result.groupInterval).to.equal('10 minutes') + expect(result.data).to.have.length.above(20).and.below(30) + + expectInterval(result, 60 * 10 * 1000) + expectTodayLastValue(result) + }) + + it('Should automatically group by one minute', async function () { + const now = new Date() + const thirtyAgo = new Date() + thirtyAgo.setMinutes(thirtyAgo.getMinutes() - 30) + + const result = await servers[0].videoStats.getTimeserieStats({ + videoId: vodVideoId, + metric: 'aggregateWatchTime', + startDate: thirtyAgo, + endDate: now + }) + + expect(result.groupInterval).to.equal('1 minute') + expect(result.data).to.have.length.above(20).and.below(40) + + expectInterval(result, 60 * 1000) + expectTodayLastValue(result) + }) + + after(async function () { + await stopFfmpeg(command) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/views/videos-views-cleaner.ts b/packages/tests/src/api/views/videos-views-cleaner.ts new file mode 100644 index 000000000..521dd9b5e --- /dev/null +++ b/packages/tests/src/api/views/videos-views-cleaner.ts @@ -0,0 +1,98 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { wait } from '@peertube/peertube-core-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + killallServers, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video views cleaner', function () { + let servers: PeerTubeServer[] + let sqlCommands: SQLCommand[] = [] + + let videoIdServer1: string + let videoIdServer2: string + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + videoIdServer1 = (await servers[0].videos.quickUpload({ name: 'video server 1' })).uuid + videoIdServer2 = (await servers[1].videos.quickUpload({ name: 'video server 2' })).uuid + + await waitJobs(servers) + + await servers[0].views.simulateView({ id: videoIdServer1 }) + await servers[1].views.simulateView({ id: videoIdServer1 }) + await servers[0].views.simulateView({ id: videoIdServer2 }) + await servers[1].views.simulateView({ id: videoIdServer2 }) + + await waitJobs(servers) + + sqlCommands = servers.map(s => new SQLCommand(s)) + }) + + it('Should not clean old video views', async function () { + this.timeout(50000) + + await killallServers([ servers[0] ]) + + await servers[0].run({ views: { videos: { remote: { max_age: '10 days' } } } }) + + await wait(6000) + + // Should still have views + + for (let i = 0; i < servers.length; i++) { + const total = await sqlCommands[i].countVideoViewsOf(videoIdServer1) + expect(total).to.equal(2, 'Server ' + servers[i].serverNumber + ' does not have the correct amount of views') + } + + for (let i = 0; i < servers.length; i++) { + const total = await sqlCommands[i].countVideoViewsOf(videoIdServer2) + expect(total).to.equal(2, 'Server ' + servers[i].serverNumber + ' does not have the correct amount of views') + } + }) + + it('Should clean old video views', async function () { + this.timeout(50000) + + await killallServers([ servers[0] ]) + + await servers[0].run({ views: { videos: { remote: { max_age: '5 seconds' } } } }) + + await wait(6000) + + // Should still have views + + for (let i = 0; i < servers.length; i++) { + const total = await sqlCommands[i].countVideoViewsOf(videoIdServer1) + expect(total).to.equal(2) + } + + const totalServer1 = await sqlCommands[0].countVideoViewsOf(videoIdServer2) + expect(totalServer1).to.equal(0) + + const totalServer2 = await sqlCommands[1].countVideoViewsOf(videoIdServer2) + expect(totalServer2).to.equal(2) + }) + + after(async function () { + for (const sqlCommand of sqlCommands) { + await sqlCommand.cleanup() + } + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/cli/create-generate-storyboard-job.ts b/packages/tests/src/cli/create-generate-storyboard-job.ts new file mode 100644 index 000000000..5a1c61ef1 --- /dev/null +++ b/packages/tests/src/cli/create-generate-storyboard-job.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { remove } from 'fs-extra/esm' +import { readdir } from 'fs/promises' +import { join } from 'path' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { SQLCommand } from '../shared/sql-command.js' + +function listStoryboardFiles (server: PeerTubeServer) { + const storage = server.getDirectoryPath('storyboards') + + return readdir(storage) +} + +describe('Test create generate storyboard job', function () { + let servers: PeerTubeServer[] = [] + const uuids: string[] = [] + let sql: SQLCommand + let existingStoryboardName: string + + before(async function () { + this.timeout(120000) + + // Run server 2 to have transcoding enabled + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + for (let i = 0; i < 3; i++) { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video ' + i }) + uuids.push(uuid) + } + + await waitJobs(servers) + + const storage = servers[0].getDirectoryPath('storyboards') + for (const storyboard of await listStoryboardFiles(servers[0])) { + await remove(join(storage, storyboard)) + } + + sql = new SQLCommand(servers[0]) + await sql.deleteAll('storyboard') + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 4' }) + uuids.push(uuid) + + await waitJobs(servers) + + const storyboards = await listStoryboardFiles(servers[0]) + existingStoryboardName = storyboards[0] + }) + + it('Should create a storyboard of a video', async function () { + this.timeout(120000) + + for (const uuid of [ uuids[0], uuids[3] ]) { + const command = `npm run create-generate-storyboard-job -- -v ${uuid}` + await servers[0].cli.execWithEnv(command) + } + + await waitJobs(servers) + + { + const storyboards = await listStoryboardFiles(servers[0]) + expect(storyboards).to.have.lengthOf(2) + expect(storyboards).to.not.include(existingStoryboardName) + + existingStoryboardName = storyboards[0] + } + + for (const server of servers) { + for (const uuid of [ uuids[0], uuids[3] ]) { + const { storyboards } = await server.storyboard.list({ id: uuid }) + expect(storyboards).to.have.lengthOf(1) + + await makeGetRequest({ url: server.url, path: storyboards[0].storyboardPath, expectedStatus: HttpStatusCode.OK_200 }) + } + } + }) + + it('Should create missing storyboards', async function () { + this.timeout(120000) + + const command = `npm run create-generate-storyboard-job -- -a` + await servers[0].cli.execWithEnv(command) + + await waitJobs(servers) + + { + const storyboards = await listStoryboardFiles(servers[0]) + expect(storyboards).to.have.lengthOf(4) + expect(storyboards).to.include(existingStoryboardName) + } + + for (const server of servers) { + for (const uuid of uuids) { + const { storyboards } = await server.storyboard.list({ id: uuid }) + expect(storyboards).to.have.lengthOf(1) + + await makeGetRequest({ url: server.url, path: storyboards[0].storyboardPath, expectedStatus: HttpStatusCode.OK_200 }) + } + } + }) + + after(async function () { + await sql.cleanup() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/cli/create-import-video-file-job.ts b/packages/tests/src/cli/create-import-video-file-job.ts new file mode 100644 index 000000000..fa934510c --- /dev/null +++ b/packages/tests/src/cli/create-import-video-file-job.ts @@ -0,0 +1,168 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, VideoDetails, VideoFile, VideoInclude } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled, buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + ObjectStorageCommand, + PeerTubeServer, + cleanupTests, + createMultipleServers, + doubleFollow, + makeRawRequest, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { expect } from 'chai' +import { expectStartWith } from '../shared/checks.js' + +function assertVideoProperties (video: VideoFile, resolution: number, extname: string, size?: number) { + expect(video).to.have.nested.property('resolution.id', resolution) + expect(video).to.have.property('torrentUrl').that.includes(`-${resolution}.torrent`) + expect(video).to.have.property('fileUrl').that.includes(`.${extname}`) + expect(video).to.have.property('magnetUri').that.includes(`.${extname}`) + expect(video).to.have.property('size').that.is.above(0) + + if (size) expect(video.size).to.equal(size) +} + +async function checkFiles (video: VideoDetails, objectStorage: ObjectStorageCommand) { + for (const file of video.files) { + if (objectStorage) expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl()) + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } +} + +function runTests (enableObjectStorage: boolean) { + let video1ShortId: string + let video2UUID: string + + let servers: PeerTubeServer[] = [] + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(90000) + + const config = enableObjectStorage + ? objectStorage.getDefaultMockConfig() + : {} + + // Run server 2 to have transcoding enabled + servers = await createMultipleServers(2, config) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + if (enableObjectStorage) await objectStorage.prepareDefaultMockBuckets() + + // Upload two videos for our needs + { + const { shortUUID } = await servers[0].videos.upload({ attributes: { name: 'video1' } }) + video1ShortId = shortUUID + } + + { + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'video2' } }) + video2UUID = uuid + } + + await waitJobs(servers) + + for (const server of servers) { + await server.config.enableTranscoding() + } + }) + + it('Should run a import job on video 1 with a lower resolution', async function () { + const command = `npm run create-import-video-file-job -- -v ${video1ShortId} -i ${buildAbsoluteFixturePath('video_short_480.webm')}` + await servers[0].cli.execWithEnv(command) + + await waitJobs(servers) + + for (const server of servers) { + const { data: videos } = await server.videos.list() + expect(videos).to.have.lengthOf(2) + + const video = videos.find(({ shortUUID }) => shortUUID === video1ShortId) + const videoDetails = await server.videos.get({ id: video.shortUUID }) + + expect(videoDetails.files).to.have.lengthOf(2) + const [ originalVideo, transcodedVideo ] = videoDetails.files + assertVideoProperties(originalVideo, 720, 'webm', 218910) + assertVideoProperties(transcodedVideo, 480, 'webm', 69217) + + await checkFiles(videoDetails, enableObjectStorage && objectStorage) + } + }) + + it('Should run a import job on video 2 with the same resolution and a different extension', async function () { + const command = `npm run create-import-video-file-job -- -v ${video2UUID} -i ${buildAbsoluteFixturePath('video_short.ogv')}` + await servers[1].cli.execWithEnv(command) + + await waitJobs(servers) + + for (const server of servers) { + const { data: videos } = await server.videos.listWithToken({ include: VideoInclude.NOT_PUBLISHED_STATE }) + expect(videos).to.have.lengthOf(2) + + const video = videos.find(({ uuid }) => uuid === video2UUID) + const videoDetails = await server.videos.get({ id: video.uuid }) + + expect(videoDetails.files).to.have.lengthOf(4) + const [ originalVideo, transcodedVideo420, transcodedVideo320, transcodedVideo240 ] = videoDetails.files + assertVideoProperties(originalVideo, 720, 'ogv', 140849) + assertVideoProperties(transcodedVideo420, 480, 'mp4') + assertVideoProperties(transcodedVideo320, 360, 'mp4') + assertVideoProperties(transcodedVideo240, 240, 'mp4') + + await checkFiles(videoDetails, enableObjectStorage && objectStorage) + } + }) + + it('Should run a import job on video 2 with the same resolution and the same extension', async function () { + const command = `npm run create-import-video-file-job -- -v ${video1ShortId} -i ${buildAbsoluteFixturePath('video_short2.webm')}` + await servers[0].cli.execWithEnv(command) + + await waitJobs(servers) + + for (const server of servers) { + const { data: videos } = await server.videos.listWithToken({ include: VideoInclude.NOT_PUBLISHED_STATE }) + expect(videos).to.have.lengthOf(2) + + const video = videos.find(({ shortUUID }) => shortUUID === video1ShortId) + const videoDetails = await server.videos.get({ id: video.uuid }) + + expect(videoDetails.files).to.have.lengthOf(2) + const [ video720, video480 ] = videoDetails.files + assertVideoProperties(video720, 720, 'webm', 942961) + assertVideoProperties(video480, 480, 'webm', 69217) + + await checkFiles(videoDetails, enableObjectStorage && objectStorage) + } + }) + + it('Should not have run transcoding after an import job', async function () { + const { data } = await servers[0].jobs.list({ jobType: 'video-transcoding' }) + expect(data).to.have.lengthOf(0) + }) + + after(async function () { + await objectStorage.cleanupMock() + + await cleanupTests(servers) + }) +} + +describe('Test create import video jobs', function () { + + describe('On filesystem', function () { + runTests(false) + }) + + describe('On object storage', function () { + if (areMockObjectStorageTestsDisabled()) return + + runTests(true) + }) +}) diff --git a/packages/tests/src/cli/create-move-video-storage-job.ts b/packages/tests/src/cli/create-move-video-storage-job.ts new file mode 100644 index 000000000..1bee7414f --- /dev/null +++ b/packages/tests/src/cli/create-move-video-storage-job.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { join } from 'path' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { HttpStatusCode, VideoDetails } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeRawRequest, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { expectStartWith } from '../shared/checks.js' +import { checkDirectoryIsEmpty } from '@tests/shared/directories.js' + +async function checkFiles (origin: PeerTubeServer, video: VideoDetails, objectStorage?: ObjectStorageCommand) { + for (const file of video.files) { + const start = objectStorage + ? objectStorage.getMockWebVideosBaseUrl() + : origin.url + + expectStartWith(file.fileUrl, start) + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + + const start = objectStorage + ? objectStorage.getMockPlaylistBaseUrl() + : origin.url + + const hls = video.streamingPlaylists[0] + expectStartWith(hls.playlistUrl, start) + expectStartWith(hls.segmentsSha256Url, start) + + for (const file of hls.files) { + expectStartWith(file.fileUrl, start) + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } +} + +describe('Test create move video storage job', function () { + if (areMockObjectStorageTestsDisabled()) return + + let servers: PeerTubeServer[] = [] + const uuids: string[] = [] + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(360000) + + // Run server 2 to have transcoding enabled + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].config.enableTranscoding() + + for (let i = 0; i < 3; i++) { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video' + i } }) + uuids.push(uuid) + } + + await waitJobs(servers) + + await servers[0].kill() + await servers[0].run(objectStorage.getDefaultMockConfig()) + }) + + it('Should move only one file', async function () { + this.timeout(120000) + + const command = `npm run create-move-video-storage-job -- --to-object-storage -v ${uuids[1]}` + await servers[0].cli.execWithEnv(command, objectStorage.getDefaultMockConfig()) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuids[1] }) + + await checkFiles(servers[0], video, objectStorage) + + for (const id of [ uuids[0], uuids[2] ]) { + const video = await server.videos.get({ id }) + + await checkFiles(servers[0], video) + } + } + }) + + it('Should move all files', async function () { + this.timeout(120000) + + const command = `npm run create-move-video-storage-job -- --to-object-storage --all-videos` + await servers[0].cli.execWithEnv(command, objectStorage.getDefaultMockConfig()) + await waitJobs(servers) + + for (const server of servers) { + for (const id of [ uuids[0], uuids[2] ]) { + const video = await server.videos.get({ id }) + + await checkFiles(servers[0], video, objectStorage) + } + } + }) + + it('Should not have files on disk anymore', async function () { + await checkDirectoryIsEmpty(servers[0], 'web-videos', [ 'private' ]) + await checkDirectoryIsEmpty(servers[0], join('web-videos', 'private')) + + await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls'), [ 'private' ]) + await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls', 'private')) + }) + + after(async function () { + await objectStorage.cleanupMock() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/cli/index.ts b/packages/tests/src/cli/index.ts new file mode 100644 index 000000000..94444ace3 --- /dev/null +++ b/packages/tests/src/cli/index.ts @@ -0,0 +1,10 @@ +// Order of the tests we want to execute +import './create-import-video-file-job' +import './create-generate-storyboard-job' +import './create-move-video-storage-job' +import './peertube' +import './plugins' +import './prune-storage' +import './regenerate-thumbnails' +import './reset-password' +import './update-host' diff --git a/packages/tests/src/cli/peertube.ts b/packages/tests/src/cli/peertube.ts new file mode 100644 index 000000000..2c66b7a18 --- /dev/null +++ b/packages/tests/src/cli/peertube.ts @@ -0,0 +1,257 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + cleanupTests, + CLICommand, + createSingleServer, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { testHelloWorldRegisteredSettings } from '../shared/plugins.js' + +describe('Test CLI wrapper', function () { + let server: PeerTubeServer + let userAccessToken: string + + let cliCommand: CLICommand + + const cmd = 'node ./apps/peertube-cli/dist/peertube.js' + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, { + rates_limit: { + login: { + max: 30 + } + } + }) + await setAccessTokensToServers([ server ]) + + await server.users.create({ username: 'user_1', password: 'super_password' }) + + userAccessToken = await server.login.getAccessToken({ username: 'user_1', password: 'super_password' }) + + { + const attributes = { name: 'user_channel', displayName: 'User channel', support: 'super support text' } + await server.channels.create({ token: userAccessToken, attributes }) + } + + cliCommand = server.cli + }) + + describe('Authentication and instance selection', function () { + + it('Should get an access token', async function () { + const stdout = await cliCommand.execWithEnv(`${cmd} token --url ${server.url} --username user_1 --password super_password`) + const token = stdout.trim() + + const body = await server.users.getMyInfo({ token }) + expect(body.username).to.equal('user_1') + }) + + it('Should display no selected instance', async function () { + this.timeout(60000) + + const stdout = await cliCommand.execWithEnv(`${cmd} --help`) + expect(stdout).to.contain('no instance selected') + }) + + it('Should add a user', async function () { + this.timeout(60000) + + await cliCommand.execWithEnv(`${cmd} auth add -u ${server.url} -U user_1 -p super_password`) + }) + + it('Should not fail to add a user if there is a slash at the end of the instance URL', async function () { + this.timeout(60000) + + let fullServerURL = server.url + '/' + + await cliCommand.execWithEnv(`${cmd} auth add -u ${fullServerURL} -U user_1 -p super_password`) + + fullServerURL = server.url + '/asdfasdf' + await cliCommand.execWithEnv(`${cmd} auth add -u ${fullServerURL} -U user_1 -p super_password`) + }) + + it('Should default to this user', async function () { + this.timeout(60000) + + const stdout = await cliCommand.execWithEnv(`${cmd} --help`) + expect(stdout).to.contain(`instance ${server.url} selected`) + }) + + it('Should remember the user', async function () { + this.timeout(60000) + + const stdout = await cliCommand.execWithEnv(`${cmd} auth list`) + expect(stdout).to.contain(server.url) + }) + }) + + describe('Video upload', function () { + + it('Should upload a video', async function () { + this.timeout(60000) + + const fixture = buildAbsoluteFixturePath('60fps_720p_small.mp4') + const params = `-f ${fixture} --video-name 'test upload' --channel-name user_channel --support 'support_text'` + + await cliCommand.execWithEnv(`${cmd} upload ${params}`) + }) + + it('Should have the video uploaded', async function () { + const { total, data } = await server.videos.list() + expect(total).to.equal(1) + + const video = await server.videos.get({ id: data[0].uuid }) + expect(video.name).to.equal('test upload') + expect(video.support).to.equal('support_text') + expect(video.channel.name).to.equal('user_channel') + }) + }) + + describe('Admin auth', function () { + + it('Should remove the auth user', async function () { + await cliCommand.execWithEnv(`${cmd} auth del ${server.url}`) + + const stdout = await cliCommand.execWithEnv(`${cmd} --help`) + expect(stdout).to.contain('no instance selected') + }) + + it('Should add the admin user', async function () { + await cliCommand.execWithEnv(`${cmd} auth add -u ${server.url} -U root -p test${server.internalServerNumber}`) + }) + }) + + describe('Manage plugins', function () { + + it('Should install a plugin', async function () { + this.timeout(60000) + + await cliCommand.execWithEnv(`${cmd} plugins install --npm-name peertube-plugin-hello-world`) + }) + + it('Should have registered settings', async function () { + await testHelloWorldRegisteredSettings(server) + }) + + it('Should list installed plugins', async function () { + const res = await cliCommand.execWithEnv(`${cmd} plugins list`) + + expect(res).to.contain('peertube-plugin-hello-world') + }) + + it('Should uninstall the plugin', async function () { + const res = await cliCommand.execWithEnv(`${cmd} plugins uninstall --npm-name peertube-plugin-hello-world`) + + expect(res).to.not.contain('peertube-plugin-hello-world') + }) + + it('Should install a plugin in requested version', async function () { + this.timeout(60000) + + await cliCommand.execWithEnv(`${cmd} plugins install --npm-name peertube-plugin-hello-world --plugin-version 0.0.17`) + }) + + it('Should list installed plugins, in correct version', async function () { + const res = await cliCommand.execWithEnv(`${cmd} plugins list`) + + expect(res).to.contain('peertube-plugin-hello-world') + expect(res).to.contain('0.0.17') + }) + + it('Should uninstall the plugin again', async function () { + const res = await cliCommand.execWithEnv(`${cmd} plugins uninstall --npm-name peertube-plugin-hello-world`) + + expect(res).to.not.contain('peertube-plugin-hello-world') + }) + + it('Should install a plugin in requested beta version', async function () { + this.timeout(60000) + + await cliCommand.execWithEnv(`${cmd} plugins install --npm-name peertube-plugin-hello-world --plugin-version 0.0.21-beta.1`) + + const res = await cliCommand.execWithEnv(`${cmd} plugins list`) + + expect(res).to.contain('peertube-plugin-hello-world') + expect(res).to.contain('0.0.21-beta.1') + + await cliCommand.execWithEnv(`${cmd} plugins uninstall --npm-name peertube-plugin-hello-world`) + }) + }) + + describe('Manage video redundancies', function () { + let anotherServer: PeerTubeServer + let video1Server2: number + let servers: PeerTubeServer[] + + before(async function () { + this.timeout(120000) + + anotherServer = await createSingleServer(2) + await setAccessTokensToServers([ anotherServer ]) + + await doubleFollow(server, anotherServer) + + servers = [ server, anotherServer ] + await waitJobs(servers) + + const { uuid } = await anotherServer.videos.quickUpload({ name: 'super video' }) + await waitJobs(servers) + + video1Server2 = await server.videos.getId({ uuid }) + }) + + it('Should add a redundancy', async function () { + this.timeout(60000) + + const params = `add --video ${video1Server2}` + await cliCommand.execWithEnv(`${cmd} redundancy ${params}`) + + await waitJobs(servers) + }) + + it('Should list redundancies', async function () { + this.timeout(60000) + + { + const params = 'list-my-redundancies' + const stdout = await cliCommand.execWithEnv(`${cmd} redundancy ${params}`) + + expect(stdout).to.contain('super video') + expect(stdout).to.contain(server.host) + } + }) + + it('Should remove a redundancy', async function () { + this.timeout(60000) + + const params = `remove --video ${video1Server2}` + await cliCommand.execWithEnv(`${cmd} redundancy ${params}`) + + await waitJobs(servers) + + { + const params = 'list-my-redundancies' + const stdout = await cliCommand.execWithEnv(`${cmd} redundancy ${params}`) + + expect(stdout).to.not.contain('super video') + } + }) + + after(async function () { + await cleanupTests([ anotherServer ]) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/cli/plugins.ts b/packages/tests/src/cli/plugins.ts new file mode 100644 index 000000000..ab7f7dd85 --- /dev/null +++ b/packages/tests/src/cli/plugins.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createSingleServer, + killallServers, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test plugin scripts', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + }) + + it('Should install a plugin from stateless CLI', async function () { + this.timeout(60000) + + const packagePath = PluginsCommand.getPluginTestPath() + + await server.cli.execWithEnv(`npm run plugin:install -- --plugin-path ${packagePath}`) + }) + + it('Should install a theme from stateless CLI', async function () { + this.timeout(60000) + + await server.cli.execWithEnv(`npm run plugin:install -- --npm-name peertube-theme-background-red`) + }) + + it('Should have the theme and the plugin registered when we restart peertube', async function () { + this.timeout(30000) + + await killallServers([ server ]) + await server.run() + + const config = await server.config.getConfig() + + const plugin = config.plugin.registered + .find(p => p.name === 'test') + expect(plugin).to.not.be.undefined + + const theme = config.theme.registered + .find(t => t.name === 'background-red') + expect(theme).to.not.be.undefined + }) + + it('Should uninstall a plugin from stateless CLI', async function () { + this.timeout(60000) + + await server.cli.execWithEnv(`npm run plugin:uninstall -- --npm-name peertube-plugin-test`) + }) + + it('Should have removed the plugin on another peertube restart', async function () { + this.timeout(30000) + + await killallServers([ server ]) + await server.run() + + const config = await server.config.getConfig() + + const plugin = config.plugin.registered + .find(p => p.name === 'test') + expect(plugin).to.be.undefined + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/cli/prune-storage.ts b/packages/tests/src/cli/prune-storage.ts new file mode 100644 index 000000000..c07a2a975 --- /dev/null +++ b/packages/tests/src/cli/prune-storage.ts @@ -0,0 +1,224 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { createFile } from 'fs-extra/esm' +import { readdir } from 'fs/promises' +import { join } from 'path' +import { wait } from '@peertube/peertube-core-utils' +import { buildUUID } from '@peertube/peertube-node-utils' +import { HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + CLICommand, + createMultipleServers, + doubleFollow, + killallServers, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +async function assertNotExists (server: PeerTubeServer, directory: string, substring: string) { + const files = await readdir(server.servers.buildDirectory(directory)) + + for (const f of files) { + expect(f).to.not.contain(substring) + } +} + +async function assertCountAreOkay (servers: PeerTubeServer[]) { + for (const server of servers) { + const videosCount = await server.servers.countFiles('web-videos') + expect(videosCount).to.equal(9) // 2 videos with 4 resolutions + private directory + + const privateVideosCount = await server.servers.countFiles('web-videos/private') + expect(privateVideosCount).to.equal(4) + + const torrentsCount = await server.servers.countFiles('torrents') + expect(torrentsCount).to.equal(24) + + const previewsCount = await server.servers.countFiles('previews') + expect(previewsCount).to.equal(3) + + const thumbnailsCount = await server.servers.countFiles('thumbnails') + expect(thumbnailsCount).to.equal(5) // 3 local videos, 1 local playlist, 2 remotes videos (lazy downloaded) and 1 remote playlist + + const avatarsCount = await server.servers.countFiles('avatars') + expect(avatarsCount).to.equal(4) + + const hlsRootCount = await server.servers.countFiles(join('streaming-playlists', 'hls')) + expect(hlsRootCount).to.equal(3) // 2 videos + private directory + + const hlsPrivateRootCount = await server.servers.countFiles(join('streaming-playlists', 'hls', 'private')) + expect(hlsPrivateRootCount).to.equal(1) + } +} + +describe('Test prune storage scripts', function () { + let servers: PeerTubeServer[] + const badNames: { [directory: string]: string[] } = {} + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2, { transcoding: { enabled: true } }) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + for (const server of servers) { + await server.videos.upload({ attributes: { name: 'video 1', privacy: VideoPrivacy.PUBLIC } }) + await server.videos.upload({ attributes: { name: 'video 2', privacy: VideoPrivacy.PUBLIC } }) + + await server.videos.upload({ attributes: { name: 'video 3', privacy: VideoPrivacy.PRIVATE } }) + + await server.users.updateMyAvatar({ fixture: 'avatar.png' }) + + await server.playlists.create({ + attributes: { + displayName: 'playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: server.store.channel.id, + thumbnailfile: 'custom-thumbnail.jpg' + } + }) + } + + await doubleFollow(servers[0], servers[1]) + + // Lazy load the remote avatars + { + const account = await servers[0].accounts.get({ accountName: 'root@' + servers[1].host }) + + for (const avatar of account.avatars) { + await makeGetRequest({ + url: servers[0].url, + path: avatar.path, + expectedStatus: HttpStatusCode.OK_200 + }) + } + } + + { + const account = await servers[1].accounts.get({ accountName: 'root@' + servers[0].host }) + for (const avatar of account.avatars) { + await makeGetRequest({ + url: servers[1].url, + path: avatar.path, + expectedStatus: HttpStatusCode.OK_200 + }) + } + } + + await wait(1000) + + await waitJobs(servers) + await killallServers(servers) + + await wait(1000) + }) + + it('Should have the files on the disk', async function () { + await assertCountAreOkay(servers) + }) + + it('Should create some dirty files', async function () { + for (let i = 0; i < 2; i++) { + { + const basePublic = servers[0].servers.buildDirectory('web-videos') + const basePrivate = servers[0].servers.buildDirectory(join('web-videos', 'private')) + + const n1 = buildUUID() + '.mp4' + const n2 = buildUUID() + '.webm' + + await createFile(join(basePublic, n1)) + await createFile(join(basePublic, n2)) + await createFile(join(basePrivate, n1)) + await createFile(join(basePrivate, n2)) + + badNames['web-videos'] = [ n1, n2 ] + } + + { + const base = servers[0].servers.buildDirectory('torrents') + + const n1 = buildUUID() + '-240.torrent' + const n2 = buildUUID() + '-480.torrent' + + await createFile(join(base, n1)) + await createFile(join(base, n2)) + + badNames['torrents'] = [ n1, n2 ] + } + + { + const base = servers[0].servers.buildDirectory('thumbnails') + + const n1 = buildUUID() + '.jpg' + const n2 = buildUUID() + '.jpg' + + await createFile(join(base, n1)) + await createFile(join(base, n2)) + + badNames['thumbnails'] = [ n1, n2 ] + } + + { + const base = servers[0].servers.buildDirectory('previews') + + const n1 = buildUUID() + '.jpg' + const n2 = buildUUID() + '.jpg' + + await createFile(join(base, n1)) + await createFile(join(base, n2)) + + badNames['previews'] = [ n1, n2 ] + } + + { + const base = servers[0].servers.buildDirectory('avatars') + + const n1 = buildUUID() + '.png' + const n2 = buildUUID() + '.jpg' + + await createFile(join(base, n1)) + await createFile(join(base, n2)) + + badNames['avatars'] = [ n1, n2 ] + } + + { + const directory = join('streaming-playlists', 'hls') + const basePublic = servers[0].servers.buildDirectory(directory) + const basePrivate = servers[0].servers.buildDirectory(join(directory, 'private')) + + const n1 = buildUUID() + await createFile(join(basePublic, n1)) + await createFile(join(basePrivate, n1)) + badNames[directory] = [ n1 ] + } + } + }) + + it('Should run prune storage', async function () { + this.timeout(30000) + + const env = servers[0].cli.getEnv() + await CLICommand.exec(`echo y | ${env} npm run prune-storage`) + }) + + it('Should have removed files', async function () { + await assertCountAreOkay(servers) + + for (const directory of Object.keys(badNames)) { + for (const name of badNames[directory]) { + await assertNotExists(servers[0], directory, name) + } + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/cli/regenerate-thumbnails.ts b/packages/tests/src/cli/regenerate-thumbnails.ts new file mode 100644 index 000000000..1448e5cfc --- /dev/null +++ b/packages/tests/src/cli/regenerate-thumbnails.ts @@ -0,0 +1,122 @@ +import { expect } from 'chai' +import { writeFile } from 'fs/promises' +import { basename, join } from 'path' +import { HttpStatusCode, Video } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +async function testThumbnail (server: PeerTubeServer, videoId: number | string) { + const video = await server.videos.get({ id: videoId }) + + const requests = [ + makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }), + makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + ] + + for (const req of requests) { + const res = await req + expect(res.body).to.not.have.lengthOf(0) + } +} + +describe('Test regenerate thumbnails script', function () { + let servers: PeerTubeServer[] + + let video1: Video + let video2: Video + let remoteVideo: Video + + let thumbnail1Path: string + let thumbnailRemotePath: string + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + { + const videoUUID1 = (await servers[0].videos.quickUpload({ name: 'video 1' })).uuid + video1 = await servers[0].videos.get({ id: videoUUID1 }) + + thumbnail1Path = join(servers[0].servers.buildDirectory('thumbnails'), basename(video1.thumbnailPath)) + + const videoUUID2 = (await servers[0].videos.quickUpload({ name: 'video 2' })).uuid + video2 = await servers[0].videos.get({ id: videoUUID2 }) + } + + { + const videoUUID = (await servers[1].videos.quickUpload({ name: 'video 3' })).uuid + await waitJobs(servers) + + remoteVideo = await servers[0].videos.get({ id: videoUUID }) + + // Load remote thumbnail on disk + await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + + thumbnailRemotePath = join(servers[0].servers.buildDirectory('thumbnails'), basename(remoteVideo.thumbnailPath)) + } + + await writeFile(thumbnail1Path, '') + await writeFile(thumbnailRemotePath, '') + }) + + it('Should have empty thumbnails', async function () { + { + const res = await makeGetRequest({ url: servers[0].url, path: video1.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.body).to.have.lengthOf(0) + } + + { + const res = await makeGetRequest({ url: servers[0].url, path: video2.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.body).to.not.have.lengthOf(0) + } + + { + const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.body).to.have.lengthOf(0) + } + }) + + it('Should regenerate local thumbnails from the CLI', async function () { + this.timeout(15000) + + await servers[0].cli.execWithEnv(`npm run regenerate-thumbnails`) + }) + + it('Should have generated new thumbnail files', async function () { + await testThumbnail(servers[0], video1.uuid) + await testThumbnail(servers[0], video2.uuid) + + const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.body).to.have.lengthOf(0) + }) + + it('Should have deleted old thumbnail files', async function () { + { + await makeGetRequest({ url: servers[0].url, path: video1.thumbnailPath, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + + { + await makeGetRequest({ url: servers[0].url, path: video2.thumbnailPath, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + + { + const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.body).to.have.lengthOf(0) + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/cli/reset-password.ts b/packages/tests/src/cli/reset-password.ts new file mode 100644 index 000000000..62e1a37a0 --- /dev/null +++ b/packages/tests/src/cli/reset-password.ts @@ -0,0 +1,26 @@ +import { cleanupTests, CLICommand, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' + +describe('Test reset password scripts', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(30000) + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + await server.users.create({ username: 'user_1', password: 'super password' }) + }) + + it('Should change the user password from CLI', async function () { + this.timeout(60000) + + const env = server.cli.getEnv() + await CLICommand.exec(`echo coucou | ${env} npm run reset-password -- -u user_1`) + + await server.login.login({ user: { username: 'user_1', password: 'coucou' } }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/cli/update-host.ts b/packages/tests/src/cli/update-host.ts new file mode 100644 index 000000000..e5f165e5e --- /dev/null +++ b/packages/tests/src/cli/update-host.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { getAllFiles } from '@peertube/peertube-core-utils' +import { + cleanupTests, + createSingleServer, + killallServers, + makeActivityPubGetRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { parseTorrentVideo } from '@tests/shared/webtorrent.js' + +describe('Test update host scripts', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(60000) + + const overrideConfig = { + webserver: { + port: 9256 + } + } + // Run server 2 to have transcoding enabled + server = await createSingleServer(2, overrideConfig) + await setAccessTokensToServers([ server ]) + + // Upload two videos for our needs + const { uuid: video1UUID } = await server.videos.upload() + await server.videos.upload() + + // Create a user + await server.users.create({ username: 'toto', password: 'coucou' }) + + // Create channel + const videoChannel = { + name: 'second_channel', + displayName: 'second video channel', + description: 'super video channel description' + } + await server.channels.create({ attributes: videoChannel }) + + // Create comments + const text = 'my super first comment' + await server.comments.createThread({ videoId: video1UUID, text }) + + await waitJobs(server) + }) + + it('Should run update host', async function () { + this.timeout(30000) + + await killallServers([ server ]) + // Run server with standard configuration + await server.run() + + await server.cli.execWithEnv(`npm run update-host`) + }) + + it('Should have updated videos url', async function () { + const { total, data } = await server.videos.list() + expect(total).to.equal(2) + + for (const video of data) { + const { body } = await makeActivityPubGetRequest(server.url, '/videos/watch/' + video.uuid) + + expect(body.id).to.equal('http://127.0.0.1:9002/videos/watch/' + video.uuid) + + const videoDetails = await server.videos.get({ id: video.uuid }) + + expect(videoDetails.trackerUrls[0]).to.include(server.host) + expect(videoDetails.streamingPlaylists[0].playlistUrl).to.include(server.host) + expect(videoDetails.streamingPlaylists[0].segmentsSha256Url).to.include(server.host) + } + }) + + it('Should have updated video channels url', async function () { + const { data, total } = await server.channels.list({ sort: '-name' }) + expect(total).to.equal(3) + + for (const channel of data) { + const { body } = await makeActivityPubGetRequest(server.url, '/video-channels/' + channel.name) + + expect(body.id).to.equal('http://127.0.0.1:9002/video-channels/' + channel.name) + } + }) + + it('Should have updated accounts url', async function () { + const body = await server.accounts.list() + expect(body.total).to.equal(3) + + for (const account of body.data) { + const usernameWithDomain = account.name + const { body } = await makeActivityPubGetRequest(server.url, '/accounts/' + usernameWithDomain) + + expect(body.id).to.equal('http://127.0.0.1:9002/accounts/' + usernameWithDomain) + } + }) + + it('Should have updated torrent hosts', async function () { + this.timeout(30000) + + const { data } = await server.videos.list() + expect(data).to.have.lengthOf(2) + + for (const video of data) { + const videoDetails = await server.videos.get({ id: video.id }) + const files = getAllFiles(videoDetails) + + expect(files).to.have.lengthOf(8) + + for (const file of files) { + expect(file.magnetUri).to.contain('127.0.0.1%3A9002%2Ftracker%2Fsocket') + expect(file.magnetUri).to.contain('127.0.0.1%3A9002%2Fstatic%2F') + + const torrent = await parseTorrentVideo(server, file) + const announceWS = torrent.announce.find(a => a === 'ws://127.0.0.1:9002/tracker/socket') + expect(announceWS).to.not.be.undefined + + const announceHttp = torrent.announce.find(a => a === 'http://127.0.0.1:9002/tracker/announce') + expect(announceHttp).to.not.be.undefined + + expect(torrent.urlList[0]).to.contain('http://127.0.0.1:9002/static/') + } + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/client.ts b/packages/tests/src/client.ts new file mode 100644 index 000000000..a16205494 --- /dev/null +++ b/packages/tests/src/client.ts @@ -0,0 +1,556 @@ +/* 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 + '') + expect(html).to.contain('') + expect(html).to.contain('') + + const htmlConfig: HTMLServerConfig = omit(config, [ 'signup' ]) + const configObjectString = JSON.stringify(htmlConfig) + const configEscapedString = JSON.stringify(configObjectString) + + expect(html).to.contain(``) +} + +describe('Test a client controllers', function () { + let servers: PeerTubeServer[] = [] + let account: Account + + const videoName = 'my super name for server 1' + const videoDescription = 'my
super __description__ for *server* 1

' + 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 = `` + + 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 = `` + + 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(``) + expect(text).to.contain(``) + expect(text).to.contain('') + expect(text).to.contain(``) + } + + 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(``) + expect(text).to.contain(``) + expect(text).to.contain('') + expect(text).to.contain(``) + } + + 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(``) + expect(text).to.contain(``) + expect(text).to.contain('') + expect(text).to.contain(``) + } + + 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(``) + expect(text).to.contain(``) + expect(text).to.contain('') + expect(text).to.contain(``) + } + + 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('') + expect(text).to.contain('') + expect(text).to.contain(``) + expect(text).to.contain(``) + } + + 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('') + expect(text).to.contain('') + expect(text).to.contain(``) + expect(text).to.contain(``) + } + + 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('') + expect(text).to.contain('') + expect(text).to.contain(``) + expect(text).to.contain(``) + } + + 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('') + expect(text).to.contain('') + expect(text).to.contain(``) + expect(text).to.contain(``) + } + + 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('') + expect(text).to.contain('') + } + + 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('') + expect(text).to.contain('') + } + + 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('') + expect(text).to.contain('') + } + + 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('') + expect(text).to.contain('') + } + + 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(``) + } + } + }) + + it('Should use the original account URL for the canonical tag', async function () { + const accountURLtest = res => { + expect(res.text).to.contain(``) + } + + 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(``) + } + + 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(``) + } + } + }) + + 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('') + } + + { + const { text } = await makeHTMLRequest(servers[0].url, path + handle) + expect(text).to.not.contain('') + } + } + }) + + 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('') + } + + { + const { text } = await makeHTMLRequest(servers[0].url, path + handle) + expect(text).to.not.contain('') + } + } + }) + + 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('') + } + }) + }) + + 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) + }) +}) diff --git a/packages/tests/src/external-plugins/akismet.ts b/packages/tests/src/external-plugins/akismet.ts new file mode 100644 index 000000000..c6d3b7752 --- /dev/null +++ b/packages/tests/src/external-plugins/akismet.ts @@ -0,0 +1,160 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Official plugin Akismet', function () { + let servers: PeerTubeServer[] + let videoUUID: string + + before(async function () { + this.timeout(30000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await servers[0].plugins.install({ + npmName: 'peertube-plugin-akismet' + }) + + if (!process.env.AKISMET_KEY) throw new Error('Missing AKISMET_KEY from env') + + await servers[0].plugins.updateSettings({ + npmName: 'peertube-plugin-akismet', + settings: { + 'akismet-api-key': process.env.AKISMET_KEY + } + }) + + await doubleFollow(servers[0], servers[1]) + }) + + describe('Local threads/replies', function () { + + before(async function () { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 1' }) + videoUUID = uuid + }) + + it('Should not detect a thread as spam', async function () { + await servers[0].comments.createThread({ videoId: videoUUID, text: 'comment' }) + }) + + it('Should not detect a reply as spam', async function () { + await servers[0].comments.addReplyToLastThread({ text: 'reply' }) + }) + + it('Should detect a thread as spam', async function () { + await servers[0].comments.createThread({ + videoId: videoUUID, + text: 'akismet-guaranteed-spam', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should detect a thread as spam', async function () { + await servers[0].comments.createThread({ videoId: videoUUID, text: 'comment' }) + await servers[0].comments.addReplyToLastThread({ text: 'akismet-guaranteed-spam', expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + }) + + describe('Remote threads/replies', function () { + + before(async function () { + this.timeout(60000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 1' }) + videoUUID = uuid + + await waitJobs(servers) + }) + + it('Should not detect a thread as spam', async function () { + this.timeout(30000) + + await servers[1].comments.createThread({ videoId: videoUUID, text: 'remote comment 1' }) + await waitJobs(servers) + + const { data } = await servers[0].comments.listThreads({ videoId: videoUUID }) + expect(data).to.have.lengthOf(1) + }) + + it('Should not detect a reply as spam', async function () { + this.timeout(30000) + + await servers[1].comments.addReplyToLastThread({ text: 'I agree with you' }) + await waitJobs(servers) + + const { data } = await servers[0].comments.listThreads({ videoId: videoUUID }) + expect(data).to.have.lengthOf(1) + + const tree = await servers[0].comments.getThread({ videoId: videoUUID, threadId: data[0].id }) + expect(tree.children).to.have.lengthOf(1) + }) + + it('Should detect a thread as spam', async function () { + this.timeout(30000) + + await servers[1].comments.createThread({ videoId: videoUUID, text: 'akismet-guaranteed-spam' }) + await waitJobs(servers) + + const { data } = await servers[0].comments.listThreads({ videoId: videoUUID }) + expect(data).to.have.lengthOf(1) + }) + + it('Should detect a thread as spam', async function () { + this.timeout(30000) + + await servers[1].comments.addReplyToLastThread({ text: 'akismet-guaranteed-spam' }) + await waitJobs(servers) + + const { data } = await servers[0].comments.listThreads({ videoId: videoUUID }) + expect(data).to.have.lengthOf(1) + + const thread = data[0] + const tree = await servers[0].comments.getThread({ videoId: videoUUID, threadId: thread.id }) + expect(tree.children).to.have.lengthOf(1) + }) + }) + + describe('Signup', function () { + + before(async function () { + await servers[0].config.updateExistingSubConfig({ + newConfig: { + signup: { + enabled: true + } + } + }) + }) + + it('Should allow signup', async function () { + await servers[0].registrations.register({ + username: 'user1', + displayName: 'user 1' + }) + }) + + it('Should detect a signup as SPAM', async function () { + await servers[0].registrations.register({ + username: 'user2', + displayName: 'user 2', + email: 'akismet-guaranteed-spam@example.com', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/external-plugins/auth-ldap.ts b/packages/tests/src/external-plugins/auth-ldap.ts new file mode 100644 index 000000000..ad058110c --- /dev/null +++ b/packages/tests/src/external-plugins/auth-ldap.ts @@ -0,0 +1,117 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' +import { HttpStatusCode } from '@peertube/peertube-models' + +describe('Official plugin auth-ldap', function () { + let server: PeerTubeServer + let accessToken: string + let userId: number + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + await server.plugins.install({ npmName: 'peertube-plugin-auth-ldap' }) + }) + + it('Should not login with without LDAP settings', async function () { + await server.login.login({ user: { username: 'fry', password: 'fry' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not login with bad LDAP settings', async function () { + await server.plugins.updateSettings({ + npmName: 'peertube-plugin-auth-ldap', + settings: { + 'bind-credentials': 'GoodNewsEveryone', + 'bind-dn': 'cn=admin,dc=planetexpress,dc=com', + 'insecure-tls': false, + 'mail-property': 'mail', + 'search-base': 'ou=people,dc=planetexpress,dc=com', + 'search-filter': '(|(mail={{username}})(uid={{username}}))', + 'url': 'ldap://127.0.0.1:390', + 'username-property': 'uid' + } + }) + + await server.login.login({ user: { username: 'fry', password: 'fry' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not login with good LDAP settings but wrong username/password', async function () { + await server.plugins.updateSettings({ + npmName: 'peertube-plugin-auth-ldap', + settings: { + 'bind-credentials': 'GoodNewsEveryone', + 'bind-dn': 'cn=admin,dc=planetexpress,dc=com', + 'insecure-tls': false, + 'mail-property': 'mail', + 'search-base': 'ou=people,dc=planetexpress,dc=com', + 'search-filter': '(|(mail={{username}})(uid={{username}}))', + 'url': 'ldap://127.0.0.1:10389', + 'username-property': 'uid' + } + }) + + await server.login.login({ user: { username: 'fry', password: 'bad password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.login.login({ user: { username: 'fryr', password: 'fry' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should login with the appropriate username/password', async function () { + accessToken = await server.login.getAccessToken({ username: 'fry', password: 'fry' }) + }) + + it('Should login with the appropriate email/password', async function () { + accessToken = await server.login.getAccessToken({ username: 'fry@planetexpress.com', password: 'fry' }) + }) + + it('Should login get my profile', async function () { + const body = await server.users.getMyInfo({ token: accessToken }) + expect(body.username).to.equal('fry') + expect(body.email).to.equal('fry@planetexpress.com') + + userId = body.id + }) + + it('Should upload a video', async function () { + await server.videos.upload({ token: accessToken, attributes: { name: 'my super video' } }) + }) + + it('Should not be able to login if the user is banned', async function () { + await server.users.banUser({ userId }) + + await server.login.login({ + user: { username: 'fry@planetexpress.com', password: 'fry' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should be able to login if the user is unbanned', async function () { + await server.users.unbanUser({ userId }) + + await server.login.login({ user: { username: 'fry@planetexpress.com', password: 'fry' } }) + }) + + it('Should not be able to ask password reset', async function () { + await server.users.askResetPassword({ email: 'fry@planetexpress.com', expectedStatus: HttpStatusCode.CONFLICT_409 }) + }) + + it('Should not be able to ask email verification', async function () { + await server.users.askSendVerifyEmail({ email: 'fry@planetexpress.com', expectedStatus: HttpStatusCode.CONFLICT_409 }) + }) + + it('Should not login if the plugin is uninstalled', async function () { + await server.plugins.uninstall({ npmName: 'peertube-plugin-auth-ldap' }) + + await server.login.login({ + user: { username: 'fry@planetexpress.com', password: 'fry' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/external-plugins/auto-block-videos.ts b/packages/tests/src/external-plugins/auto-block-videos.ts new file mode 100644 index 000000000..6146c827c --- /dev/null +++ b/packages/tests/src/external-plugins/auto-block-videos.ts @@ -0,0 +1,167 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { Video } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + killallServers, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { MockBlocklist } from '../shared/mock-servers/index.js' + +async function check (server: PeerTubeServer, videoUUID: string, exists = true) { + const { data } = await server.videos.list() + + const video = data.find(v => v.uuid === videoUUID) + + if (exists) expect(video).to.not.be.undefined + else expect(video).to.be.undefined +} + +describe('Official plugin auto-block videos', function () { + let servers: PeerTubeServer[] + let blocklistServer: MockBlocklist + let server1Videos: Video[] = [] + let server2Videos: Video[] = [] + let port: number + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + for (const server of servers) { + await server.plugins.install({ npmName: 'peertube-plugin-auto-block-videos' }) + } + + blocklistServer = new MockBlocklist() + port = await blocklistServer.initialize() + + await servers[0].videos.quickUpload({ name: 'video server 1' }) + await servers[1].videos.quickUpload({ name: 'video server 2' }) + await servers[1].videos.quickUpload({ name: 'video 2 server 2' }) + await servers[1].videos.quickUpload({ name: 'video 3 server 2' }) + + { + const { data } = await servers[0].videos.list() + server1Videos = data.map(v => Object.assign(v, { url: servers[0].url + '/videos/watch/' + v.uuid })) + } + + { + const { data } = await servers[1].videos.list() + server2Videos = data.map(v => Object.assign(v, { url: servers[1].url + '/videos/watch/' + v.uuid })) + } + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should update plugin settings', async function () { + await servers[0].plugins.updateSettings({ + npmName: 'peertube-plugin-auto-block-videos', + settings: { + 'blocklist-urls': `http://127.0.0.1:${port}/blocklist`, + 'check-seconds-interval': 1 + } + }) + }) + + it('Should auto block a video', async function () { + await check(servers[0], server2Videos[0].uuid, true) + + blocklistServer.replace({ + data: [ + { + value: server2Videos[0].url + } + ] + }) + + await wait(2000) + + await check(servers[0], server2Videos[0].uuid, false) + }) + + it('Should have video in blacklists', async function () { + const body = await servers[0].blacklist.list() + + const videoBlacklists = body.data + expect(videoBlacklists).to.have.lengthOf(1) + expect(videoBlacklists[0].reason).to.contains('Automatically blocked from auto block plugin') + expect(videoBlacklists[0].video.name).to.equal(server2Videos[0].name) + }) + + it('Should not block a local video', async function () { + await check(servers[0], server1Videos[0].uuid, true) + + blocklistServer.replace({ + data: [ + { + value: server1Videos[0].url + } + ] + }) + + await wait(2000) + + await check(servers[0], server1Videos[0].uuid, true) + }) + + it('Should remove a video block', async function () { + await check(servers[0], server2Videos[0].uuid, false) + + blocklistServer.replace({ + data: [ + { + value: server2Videos[0].url, + action: 'remove' + } + ] + }) + + await wait(2000) + + await check(servers[0], server2Videos[0].uuid, true) + }) + + it('Should auto block a video, manually unblock it and do not reblock it automatically', async function () { + this.timeout(20000) + + const video = server2Videos[1] + + await check(servers[0], video.uuid, true) + + blocklistServer.replace({ + data: [ + { + value: video.url, + updatedAt: new Date().toISOString() + } + ] + }) + + await wait(2000) + + await check(servers[0], video.uuid, false) + + await servers[0].blacklist.remove({ videoId: video.uuid }) + + await check(servers[0], video.uuid, true) + + await killallServers([ servers[0] ]) + await servers[0].run() + await wait(2000) + + await check(servers[0], video.uuid, true) + }) + + after(async function () { + await blocklistServer.terminate() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/external-plugins/auto-mute.ts b/packages/tests/src/external-plugins/auto-mute.ts new file mode 100644 index 000000000..b4050e236 --- /dev/null +++ b/packages/tests/src/external-plugins/auto-mute.ts @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + killallServers, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { MockBlocklist } from '../shared/mock-servers/index.js' + +describe('Official plugin auto-mute', function () { + const autoMuteListPath = '/plugins/auto-mute/router/api/v1/mute-list' + let servers: PeerTubeServer[] + let blocklistServer: MockBlocklist + let port: number + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + for (const server of servers) { + await server.plugins.install({ npmName: 'peertube-plugin-auto-mute' }) + } + + blocklistServer = new MockBlocklist() + port = await blocklistServer.initialize() + + await servers[0].videos.quickUpload({ name: 'video server 1' }) + await servers[1].videos.quickUpload({ name: 'video server 2' }) + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should update plugin settings', async function () { + await servers[0].plugins.updateSettings({ + npmName: 'peertube-plugin-auto-mute', + settings: { + 'blocklist-urls': `http://127.0.0.1:${port}/blocklist`, + 'check-seconds-interval': 1 + } + }) + }) + + it('Should add a server blocklist', async function () { + blocklistServer.replace({ + data: [ + { + value: servers[1].host + } + ] + }) + + await wait(2000) + + const { total } = await servers[0].videos.list() + expect(total).to.equal(1) + }) + + it('Should remove a server blocklist', async function () { + blocklistServer.replace({ + data: [ + { + value: servers[1].host, + action: 'remove' + } + ] + }) + + await wait(2000) + + const { total } = await servers[0].videos.list() + expect(total).to.equal(2) + }) + + it('Should add an account blocklist', async function () { + blocklistServer.replace({ + data: [ + { + value: 'root@' + servers[1].host + } + ] + }) + + await wait(2000) + + const { total } = await servers[0].videos.list() + expect(total).to.equal(1) + }) + + it('Should remove an account blocklist', async function () { + blocklistServer.replace({ + data: [ + { + value: 'root@' + servers[1].host, + action: 'remove' + } + ] + }) + + await wait(2000) + + const { total } = await servers[0].videos.list() + expect(total).to.equal(2) + }) + + it('Should auto mute an account, manually unmute it and do not remute it automatically', async function () { + this.timeout(20000) + + const account = 'root@' + servers[1].host + + blocklistServer.replace({ + data: [ + { + value: account, + updatedAt: new Date().toISOString() + } + ] + }) + + await wait(2000) + + { + const { total } = await servers[0].videos.list() + expect(total).to.equal(1) + } + + await servers[0].blocklist.removeFromServerBlocklist({ account }) + + { + const { total } = await servers[0].videos.list() + expect(total).to.equal(2) + } + + await killallServers([ servers[0] ]) + await servers[0].run() + await wait(2000) + + { + const { total } = await servers[0].videos.list() + expect(total).to.equal(2) + } + }) + + it('Should not expose the auto mute list', async function () { + await makeGetRequest({ + url: servers[0].url, + path: '/plugins/auto-mute/router/api/v1/mute-list', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should enable auto mute list', async function () { + await servers[0].plugins.updateSettings({ + npmName: 'peertube-plugin-auto-mute', + settings: { + 'blocklist-urls': '', + 'check-seconds-interval': 1, + 'expose-mute-list': true + } + }) + + await makeGetRequest({ + url: servers[0].url, + path: '/plugins/auto-mute/router/api/v1/mute-list', + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should mute an account on server 1, and server 2 auto mutes it', async function () { + this.timeout(20000) + + await servers[1].plugins.updateSettings({ + npmName: 'peertube-plugin-auto-mute', + settings: { + 'blocklist-urls': 'http://' + servers[0].host + autoMuteListPath, + 'check-seconds-interval': 1, + 'expose-mute-list': false + } + }) + + await servers[0].blocklist.addToServerBlocklist({ account: 'root@' + servers[1].host }) + await servers[0].blocklist.addToMyBlocklist({ server: servers[1].host }) + + const res = await makeGetRequest({ + url: servers[0].url, + path: '/plugins/auto-mute/router/api/v1/mute-list', + expectedStatus: HttpStatusCode.OK_200 + }) + + const data = res.body.data + expect(data).to.have.lengthOf(1) + expect(data[0].updatedAt).to.exist + expect(data[0].value).to.equal('root@' + servers[1].host) + + await wait(2000) + + for (const server of servers) { + const { total } = await server.videos.list() + expect(total).to.equal(1) + } + }) + + after(async function () { + await blocklistServer.terminate() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/external-plugins/index.ts b/packages/tests/src/external-plugins/index.ts new file mode 100644 index 000000000..815bbf1da --- /dev/null +++ b/packages/tests/src/external-plugins/index.ts @@ -0,0 +1,4 @@ +import './akismet' +import './auth-ldap' +import './auto-block-videos' +import './auto-mute' diff --git a/packages/tests/src/feeds/feeds.ts b/packages/tests/src/feeds/feeds.ts new file mode 100644 index 000000000..7587bb34e --- /dev/null +++ b/packages/tests/src/feeds/feeds.ts @@ -0,0 +1,697 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import * as chai from 'chai' +import chaiJSONSChema from 'chai-json-schema' +import chaiXML from 'chai-xml' +import { XMLParser, XMLValidator } from 'fast-xml-parser' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + createSingleServer, + doubleFollow, + makeGetRequest, + makeRawRequest, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers, + setDefaultChannelAvatar, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' + +chai.use(chaiXML) +chai.use(chaiJSONSChema) +chai.config.includeStack = true + +const expect = chai.expect + +describe('Test syndication feeds', () => { + let servers: PeerTubeServer[] = [] + let serverHLSOnly: PeerTubeServer + + let userAccessToken: string + let rootAccountId: number + let rootChannelId: number + + let userAccountId: number + let userChannelId: number + let userFeedToken: string + + let liveId: string + + before(async function () { + this.timeout(120000) + + // Run servers + servers = await createMultipleServers(2) + serverHLSOnly = await createSingleServer(3, { + transcoding: { + enabled: true, + web_videos: { enabled: false }, + hls: { enabled: true } + } + }) + + await setAccessTokensToServers([ ...servers, serverHLSOnly ]) + await setDefaultChannelAvatar(servers[0]) + await setDefaultVideoChannel(servers) + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableLive({ allowReplay: false, transcoding: false }) + + { + const user = await servers[0].users.getMyInfo() + rootAccountId = user.account.id + rootChannelId = user.videoChannels[0].id + } + + { + userAccessToken = await servers[0].users.generateUserAndToken('john') + + const user = await servers[0].users.getMyInfo({ token: userAccessToken }) + userAccountId = user.account.id + userChannelId = user.videoChannels[0].id + + const token = await servers[0].users.getMyScopedTokens({ token: userAccessToken }) + userFeedToken = token.feedToken + } + + { + await servers[0].videos.upload({ token: userAccessToken, attributes: { name: 'user video' } }) + } + + { + const attributes = { + name: 'my super name for server 1', + description: 'my super description for server 1', + fixture: 'video_short.webm' + } + const { id } = await servers[0].videos.upload({ attributes }) + + await servers[0].comments.createThread({ videoId: id, text: 'super comment 1' }) + await servers[0].comments.createThread({ videoId: id, text: 'super comment 2' }) + } + + { + const attributes = { name: 'unlisted video', privacy: VideoPrivacy.UNLISTED } + const { id } = await servers[0].videos.upload({ attributes }) + + await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' }) + } + + { + const attributes = { name: 'password protected video', privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] } + const { id } = await servers[0].videos.upload({ attributes }) + + await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' }) + } + + await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } }) + + await waitJobs([ ...servers, serverHLSOnly ]) + + await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-podcast-custom-tags') }) + }) + + describe('All feed', function () { + + it('Should be well formed XML (covers RSS 2.0 and ATOM 1.0 endpoints)', async function () { + for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) { + const rss = await servers[0].feed.getXML({ feed, ignoreCache: true }) + expect(rss).xml.to.be.valid() + + const atom = await servers[0].feed.getXML({ feed, format: 'atom', ignoreCache: true }) + expect(atom).xml.to.be.valid() + } + }) + + it('Should be well formed XML (covers Podcast endpoint)', async function () { + const podcast = await servers[0].feed.getPodcastXML({ ignoreCache: true, channelId: rootChannelId }) + expect(podcast).xml.to.be.valid() + }) + + it('Should be well formed JSON (covers JSON feed 1.0 endpoint)', async function () { + for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) { + const jsonText = await servers[0].feed.getJSON({ feed, ignoreCache: true }) + expect(JSON.parse(jsonText)).to.be.jsonSchema({ type: 'object' }) + } + }) + + it('Should serve the endpoint with a classic request', async function () { + await makeGetRequest({ + url: servers[0].url, + path: '/feeds/videos.xml', + accept: 'application/xml', + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should refuse to serve the endpoint without accept header', async function () { + await makeGetRequest({ url: servers[0].url, path: '/feeds/videos.xml', expectedStatus: HttpStatusCode.NOT_ACCEPTABLE_406 }) + }) + }) + + describe('Videos feed', function () { + + describe('Podcast feed', function () { + + it('Should contain a valid podcast:alternateEnclosure', async function () { + // Since podcast feeds should only work on the server they originate on, + // only test the first server where the videos reside + const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId }) + expect(XMLValidator.validate(rss)).to.be.true + + const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false }) + const xmlDoc = parser.parse(rss) + + const itemGuid = xmlDoc.rss.channel.item.guid + expect(itemGuid).to.exist + expect(itemGuid['@_isPermaLink']).to.equal(true) + + const enclosure = xmlDoc.rss.channel.item.enclosure + expect(enclosure).to.exist + const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure'] + expect(alternateEnclosure).to.exist + + expect(alternateEnclosure['@_type']).to.equal('video/webm') + expect(alternateEnclosure['@_length']).to.equal(218910) + expect(alternateEnclosure['@_lang']).to.equal('zh') + expect(alternateEnclosure['@_title']).to.equal('720p') + expect(alternateEnclosure['@_default']).to.equal(true) + + expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.contain('-720.webm') + expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.equal(enclosure['@_url']) + expect(alternateEnclosure['podcast:source'][1]['@_uri']).to.contain('-720.torrent') + expect(alternateEnclosure['podcast:source'][1]['@_contentType']).to.equal('application/x-bittorrent') + expect(alternateEnclosure['podcast:source'][2]['@_uri']).to.contain('magnet:?') + }) + + it('Should contain a valid podcast:alternateEnclosure with HLS only', async function () { + const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId }) + expect(XMLValidator.validate(rss)).to.be.true + + const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false }) + const xmlDoc = parser.parse(rss) + + const itemGuid = xmlDoc.rss.channel.item.guid + expect(itemGuid).to.exist + expect(itemGuid['@_isPermaLink']).to.equal(true) + + const enclosure = xmlDoc.rss.channel.item.enclosure + const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure'] + expect(alternateEnclosure).to.exist + + expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL') + expect(alternateEnclosure['@_lang']).to.equal('zh') + expect(alternateEnclosure['@_title']).to.equal('HLS') + expect(alternateEnclosure['@_default']).to.equal(true) + + expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('-master.m3u8') + expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url']) + }) + + it('Should contain a valid podcast:socialInteract', async function () { + const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId }) + expect(XMLValidator.validate(rss)).to.be.true + + const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false }) + const xmlDoc = parser.parse(rss) + + const item = xmlDoc.rss.channel.item + const socialInteract = item['podcast:socialInteract'] + expect(socialInteract).to.exist + expect(socialInteract['@_protocol']).to.equal('activitypub') + expect(socialInteract['@_uri']).to.exist + expect(socialInteract['@_accountUrl']).to.exist + }) + + it('Should contain a valid support custom tags for plugins', async function () { + const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: userChannelId }) + expect(XMLValidator.validate(rss)).to.be.true + + const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false }) + const xmlDoc = parser.parse(rss) + + const fooTag = xmlDoc.rss.channel.fooTag + expect(fooTag).to.exist + expect(fooTag['@_bar']).to.equal('baz') + expect(fooTag['#text']).to.equal(42) + + const bizzBuzzItem = xmlDoc.rss.channel['biz:buzzItem'] + expect(bizzBuzzItem).to.exist + + let nestedTag = bizzBuzzItem.nestedTag + expect(nestedTag).to.exist + expect(nestedTag).to.equal('example nested tag') + + const item = xmlDoc.rss.channel.item + const fizzTag = item.fizzTag + expect(fizzTag).to.exist + expect(fizzTag['@_bar']).to.equal('baz') + expect(fizzTag['#text']).to.equal(21) + + const bizzBuzz = item['biz:buzz'] + expect(bizzBuzz).to.exist + + nestedTag = bizzBuzz.nestedTag + expect(nestedTag).to.exist + expect(nestedTag).to.equal('example nested tag') + }) + + it('Should contain a valid podcast:liveItem for live streams', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].live.create({ + fields: { + name: 'live-0', + privacy: VideoPrivacy.PUBLIC, + channelId: rootChannelId, + permanentLive: false + } + }) + liveId = uuid + + const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' }) + await servers[0].live.waitUntilPublished({ videoId: liveId }) + + const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId }) + expect(XMLValidator.validate(rss)).to.be.true + + const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false }) + const xmlDoc = parser.parse(rss) + const liveItem = xmlDoc.rss.channel['podcast:liveItem'] + expect(liveItem.title).to.equal('live-0') + expect(liveItem.guid['@_isPermaLink']).to.equal(false) + expect(liveItem.guid['#text']).to.contain(`${uuid}_`) + expect(liveItem['@_status']).to.equal('live') + + const enclosure = liveItem.enclosure + const alternateEnclosure = liveItem['podcast:alternateEnclosure'] + expect(alternateEnclosure).to.exist + expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL') + expect(alternateEnclosure['@_title']).to.equal('HLS live stream') + expect(alternateEnclosure['@_default']).to.equal(true) + + expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('/master.m3u8') + expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url']) + + await stopFfmpeg(ffmpeg) + + await servers[0].live.waitUntilEnded({ videoId: liveId }) + + await waitJobs(servers) + }) + }) + + describe('JSON feed', function () { + + it('Should contain a valid \'attachments\' object', async function () { + for (const server of servers) { + const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(2) + expect(jsonObj.items[0].attachments).to.exist + expect(jsonObj.items[0].attachments.length).to.be.eq(1) + expect(jsonObj.items[0].attachments[0].mime_type).to.be.eq('application/x-bittorrent') + expect(jsonObj.items[0].attachments[0].size_in_bytes).to.be.eq(218910) + expect(jsonObj.items[0].attachments[0].url).to.contain('720.torrent') + } + }) + + it('Should filter by account', async function () { + { + const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: rootAccountId }, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) + expect(jsonObj.items[0].title).to.equal('my super name for server 1') + expect(jsonObj.items[0].author.name).to.equal('Main root channel') + } + + { + const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: userAccountId }, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) + expect(jsonObj.items[0].title).to.equal('user video') + expect(jsonObj.items[0].author.name).to.equal('Main john channel') + } + + for (const server of servers) { + { + const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'root@' + servers[0].host }, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) + expect(jsonObj.items[0].title).to.equal('my super name for server 1') + } + + { + const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'john@' + servers[0].host }, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) + expect(jsonObj.items[0].title).to.equal('user video') + } + } + }) + + it('Should filter by video channel', async function () { + { + const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) + expect(jsonObj.items[0].title).to.equal('my super name for server 1') + expect(jsonObj.items[0].author.name).to.equal('Main root channel') + } + + { + const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: userChannelId }, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) + expect(jsonObj.items[0].title).to.equal('user video') + expect(jsonObj.items[0].author.name).to.equal('Main john channel') + } + + for (const server of servers) { + { + const query = { videoChannelName: 'root_channel@' + servers[0].host } + const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) + expect(jsonObj.items[0].title).to.equal('my super name for server 1') + } + + { + const query = { videoChannelName: 'john_channel@' + servers[0].host } + const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) + expect(jsonObj.items[0].title).to.equal('user video') + } + } + }) + + it('Should correctly have videos feed with HLS only', async function () { + this.timeout(120000) + + const json = await serverHLSOnly.feed.getJSON({ feed: 'videos', ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) + expect(jsonObj.items[0].attachments).to.exist + expect(jsonObj.items[0].attachments.length).to.be.eq(4) + + for (let i = 0; i < 4; i++) { + expect(jsonObj.items[0].attachments[i].mime_type).to.be.eq('application/x-bittorrent') + expect(jsonObj.items[0].attachments[i].size_in_bytes).to.be.greaterThan(0) + expect(jsonObj.items[0].attachments[i].url).to.exist + } + }) + + it('Should not display waiting live videos', async function () { + const { uuid } = await servers[0].live.create({ + fields: { + name: 'live', + privacy: VideoPrivacy.PUBLIC, + channelId: rootChannelId + } + }) + liveId = uuid + + const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true }) + + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(2) + expect(jsonObj.items[0].title).to.equal('my super name for server 1') + expect(jsonObj.items[1].title).to.equal('user video') + }) + + it('Should display published live videos', async function () { + this.timeout(120000) + + const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' }) + await servers[0].live.waitUntilPublished({ videoId: liveId }) + + const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true }) + + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(3) + expect(jsonObj.items[0].title).to.equal('live') + expect(jsonObj.items[1].title).to.equal('my super name for server 1') + expect(jsonObj.items[2].title).to.equal('user video') + + await stopFfmpeg(ffmpeg) + }) + + it('Should have the channel avatar as feed icon', async function () { + const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true }) + + const jsonObj = JSON.parse(json) + const imageUrl = jsonObj.icon + expect(imageUrl).to.include('/lazy-static/avatars/') + await makeRawRequest({ url: imageUrl, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + }) + + describe('Video comments feed', function () { + + it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted/password protected videos', async function () { + for (const server of servers) { + const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) + + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(2) + expect(jsonObj.items[0].content_html).to.contain('

super comment 2

') + expect(jsonObj.items[1].content_html).to.contain('

super comment 1

') + } + }) + + it('Should not list comments from muted accounts or instances', async function () { + this.timeout(30000) + + const remoteHandle = 'root@' + servers[0].host + + await servers[1].blocklist.addToServerBlocklist({ account: remoteHandle }) + + { + const json = await servers[1].feed.getJSON({ feed: 'video-comments', ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(0) + } + + await servers[1].blocklist.removeFromServerBlocklist({ account: remoteHandle }) + + { + const videoUUID = (await servers[1].videos.quickUpload({ name: 'server 2' })).uuid + await waitJobs(servers) + await servers[0].comments.createThread({ videoId: videoUUID, text: 'super comment' }) + await waitJobs(servers) + + const json = await servers[1].feed.getJSON({ feed: 'video-comments', ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(3) + } + + await servers[1].blocklist.addToMyBlocklist({ account: remoteHandle }) + + { + const json = await servers[1].feed.getJSON({ feed: 'video-comments', ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(2) + } + }) + }) + + describe('Video feed from my subscriptions', function () { + let feeduserAccountId: number + let feeduserFeedToken: string + + it('Should list no videos for a user with no videos and no subscriptions', async function () { + const attr = { username: 'feeduser', password: 'password' } + await servers[0].users.create({ username: attr.username, password: attr.password }) + const feeduserAccessToken = await servers[0].login.getAccessToken(attr) + + { + const user = await servers[0].users.getMyInfo({ token: feeduserAccessToken }) + feeduserAccountId = user.account.id + } + + { + const token = await servers[0].users.getMyScopedTokens({ token: feeduserAccessToken }) + feeduserFeedToken = token.feedToken + } + + { + const body = await servers[0].videos.listMySubscriptionVideos({ token: feeduserAccessToken }) + expect(body.total).to.equal(0) + + const query = { accountId: feeduserAccountId, token: feeduserFeedToken } + const json = await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos + } + }) + + it('Should fail with an invalid token', async function () { + const query = { accountId: feeduserAccountId, token: 'toto' } + await servers[0].feed.getJSON({ feed: 'subscriptions', query, expectedStatus: HttpStatusCode.FORBIDDEN_403, ignoreCache: true }) + }) + + it('Should fail with a token of another user', async function () { + const query = { accountId: feeduserAccountId, token: userFeedToken } + await servers[0].feed.getJSON({ feed: 'subscriptions', query, expectedStatus: HttpStatusCode.FORBIDDEN_403, ignoreCache: true }) + }) + + it('Should list no videos for a user with videos but no subscriptions', async function () { + const body = await servers[0].videos.listMySubscriptionVideos({ token: userAccessToken }) + expect(body.total).to.equal(0) + + const query = { accountId: userAccountId, token: userFeedToken } + const json = await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos + }) + + it('Should list self videos for a user with a subscription to themselves', async function () { + this.timeout(30000) + + await servers[0].subscriptions.add({ token: userAccessToken, targetUri: 'john_channel@' + servers[0].host }) + await waitJobs(servers) + + { + const body = await servers[0].videos.listMySubscriptionVideos({ token: userAccessToken }) + expect(body.total).to.equal(1) + expect(body.data[0].name).to.equal('user video') + + const query = { accountId: userAccountId, token: userFeedToken } + const json = await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(1) // subscribed to self, it should not list the instance's videos but list john's + } + }) + + it('Should list videos of a user\'s subscription', async function () { + this.timeout(30000) + + await servers[0].subscriptions.add({ token: userAccessToken, targetUri: 'root_channel@' + servers[0].host }) + await waitJobs(servers) + + { + const body = await servers[0].videos.listMySubscriptionVideos({ token: userAccessToken }) + expect(body.total).to.equal(2, 'there should be 2 videos part of the subscription') + + const query = { accountId: userAccountId, token: userFeedToken } + const json = await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) + const jsonObj = JSON.parse(json) + expect(jsonObj.items.length).to.be.equal(2) // subscribed to root, it should not list the instance's videos but list root/john's + } + }) + + it('Should renew the token, and so have an invalid old token', async function () { + await servers[0].users.renewMyScopedTokens({ token: userAccessToken }) + + const query = { accountId: userAccountId, token: userFeedToken } + await servers[0].feed.getJSON({ feed: 'subscriptions', query, expectedStatus: HttpStatusCode.FORBIDDEN_403, ignoreCache: true }) + }) + + it('Should succeed with the new token', async function () { + const token = await servers[0].users.getMyScopedTokens({ token: userAccessToken }) + userFeedToken = token.feedToken + + const query = { accountId: userAccountId, token: userFeedToken } + await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) + }) + + }) + + describe('Cache', function () { + const uuids: string[] = [] + + function doPodcastRequest () { + return makeGetRequest({ + url: servers[0].url, + path: '/feeds/podcast/videos.xml', + query: { videoChannelId: servers[0].store.channel.id }, + accept: 'application/xml', + expectedStatus: HttpStatusCode.OK_200 + }) + } + + function doVideosRequest (query: { [id: string]: string } = {}) { + return makeGetRequest({ + url: servers[0].url, + path: '/feeds/videos.xml', + query, + accept: 'application/xml', + expectedStatus: HttpStatusCode.OK_200 + }) + } + + before(async function () { + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'cache 1' }) + uuids.push(uuid) + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'cache 2' }) + uuids.push(uuid) + } + }) + + it('Should serve the videos endpoint as a cached request', async function () { + await doVideosRequest() + + const res = await doVideosRequest() + + expect(res.headers['x-api-cache-cached']).to.equal('true') + }) + + it('Should not serve the videos endpoint as a cached request', async function () { + const res = await doVideosRequest({ v: '186' }) + + expect(res.headers['x-api-cache-cached']).to.not.exist + }) + + it('Should invalidate the podcast feed cache after video deletion', async function () { + await doPodcastRequest() + + { + const res = await doPodcastRequest() + expect(res.headers['x-api-cache-cached']).to.exist + } + + await servers[0].videos.remove({ id: uuids[0] }) + + { + const res = await doPodcastRequest() + expect(res.headers['x-api-cache-cached']).to.not.exist + } + }) + + it('Should invalidate the podcast feed cache after video deletion, even after server restart', async function () { + this.timeout(120000) + + await doPodcastRequest() + + { + const res = await doPodcastRequest() + expect(res.headers['x-api-cache-cached']).to.exist + } + + await servers[0].kill() + await servers[0].run() + + await servers[0].videos.remove({ id: uuids[1] }) + + const res = await doPodcastRequest() + expect(res.headers['x-api-cache-cached']).to.not.exist + }) + + }) + + after(async function () { + await servers[0].plugins.uninstall({ npmName: 'peertube-plugin-test-podcast-custom-tags' }) + + await cleanupTests([ ...servers, serverHLSOnly ]) + }) +}) diff --git a/packages/tests/src/feeds/index.ts b/packages/tests/src/feeds/index.ts new file mode 100644 index 000000000..aa6236a91 --- /dev/null +++ b/packages/tests/src/feeds/index.ts @@ -0,0 +1 @@ +import './feeds' diff --git a/packages/tests/src/misc-endpoints.ts b/packages/tests/src/misc-endpoints.ts new file mode 100644 index 000000000..0067578ed --- /dev/null +++ b/packages/tests/src/misc-endpoints.ts @@ -0,0 +1,243 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { writeJson } from 'fs-extra/esm' +import { join } from 'path' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { expectLogDoesNotContain } from './shared/checks.js' + +describe('Test misc endpoints', function () { + let server: PeerTubeServer + let wellKnownPath: string + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + wellKnownPath = server.getDirectoryPath('well-known') + }) + + describe('Test a well known endpoints', function () { + + it('Should get security.txt', async function () { + const res = await makeGetRequest({ + url: server.url, + path: '/.well-known/security.txt', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.text).to.contain('security issue') + }) + + it('Should get nodeinfo', async function () { + const res = await makeGetRequest({ + url: server.url, + path: '/.well-known/nodeinfo', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.links).to.be.an('array') + expect(res.body.links).to.have.lengthOf(1) + expect(res.body.links[0].rel).to.equal('http://nodeinfo.diaspora.software/ns/schema/2.0') + }) + + it('Should get dnt policy text', async function () { + const res = await makeGetRequest({ + url: server.url, + path: '/.well-known/dnt-policy.txt', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.text).to.contain('http://www.w3.org/TR/tracking-dnt') + }) + + it('Should get dnt policy', async function () { + const res = await makeGetRequest({ + url: server.url, + path: '/.well-known/dnt', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.tracking).to.equal('N') + }) + + it('Should get change-password location', async function () { + const res = await makeGetRequest({ + url: server.url, + path: '/.well-known/change-password', + expectedStatus: HttpStatusCode.FOUND_302 + }) + + expect(res.header.location).to.equal('/my-account/settings') + }) + + it('Should test webfinger', async function () { + const resource = 'acct:peertube@' + server.host + const accountUrl = server.url + '/accounts/peertube' + + const res = await makeGetRequest({ + url: server.url, + path: '/.well-known/webfinger?resource=' + resource, + expectedStatus: HttpStatusCode.OK_200 + }) + + const data = res.body + + expect(data.subject).to.equal(resource) + expect(data.aliases).to.contain(accountUrl) + + const self = data.links.find(l => l.rel === 'self') + expect(self).to.exist + expect(self.type).to.equal('application/activity+json') + expect(self.href).to.equal(accountUrl) + + const remoteInteract = data.links.find(l => l.rel === 'http://ostatus.org/schema/1.0/subscribe') + expect(remoteInteract).to.exist + expect(remoteInteract.template).to.equal(server.url + '/remote-interaction?uri={uri}') + }) + + it('Should return 404 for non-existing files in /.well-known', async function () { + await makeGetRequest({ + url: server.url, + path: '/.well-known/non-existing-file', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should return custom file from /.well-known', async function () { + const filename = 'existing-file.json' + + await writeJson(join(wellKnownPath, filename), { iThink: 'therefore I am' }) + + const { body } = await makeGetRequest({ + url: server.url, + path: '/.well-known/' + filename, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(body.iThink).to.equal('therefore I am') + }) + }) + + describe('Test classic static endpoints', function () { + + it('Should get robots.txt', async function () { + const res = await makeGetRequest({ + url: server.url, + path: '/robots.txt', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.text).to.contain('User-agent') + }) + + it('Should get security.txt', async function () { + await makeGetRequest({ + url: server.url, + path: '/security.txt', + expectedStatus: HttpStatusCode.MOVED_PERMANENTLY_301 + }) + }) + + it('Should get nodeinfo', async function () { + const res = await makeGetRequest({ + url: server.url, + path: '/nodeinfo/2.0.json', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.software.name).to.equal('peertube') + expect(res.body.usage.users.activeMonth).to.equal(1) + expect(res.body.usage.users.activeHalfyear).to.equal(1) + }) + }) + + describe('Test bots endpoints', function () { + + it('Should get the empty sitemap', async function () { + const res = await makeGetRequest({ + url: server.url, + path: '/sitemap.xml', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.text).to.contain('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"') + expect(res.text).to.contain('' + server.url + '/about/instance') + }) + + it('Should get the empty cached sitemap', async function () { + const res = await makeGetRequest({ + url: server.url, + path: '/sitemap.xml', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.text).to.contain('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"') + expect(res.text).to.contain('' + server.url + '/about/instance') + }) + + it('Should add videos, channel and accounts and get sitemap', async function () { + this.timeout(35000) + + await server.videos.upload({ attributes: { name: 'video 1', nsfw: false } }) + await server.videos.upload({ attributes: { name: 'video 2', nsfw: false } }) + await server.videos.upload({ attributes: { name: 'video 3', privacy: VideoPrivacy.PRIVATE } }) + + await server.channels.create({ attributes: { name: 'channel1', displayName: 'channel 1' } }) + await server.channels.create({ attributes: { name: 'channel2', displayName: 'channel 2' } }) + + await server.users.create({ username: 'user1', password: 'password' }) + await server.users.create({ username: 'user2', password: 'password' }) + + const res = await makeGetRequest({ + url: server.url, + path: '/sitemap.xml?t=1', // avoid using cache + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.text).to.contain('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"') + expect(res.text).to.contain('' + server.url + '/about/instance') + + expect(res.text).to.contain('video 1') + expect(res.text).to.contain('video 2') + expect(res.text).to.not.contain('video 3') + + expect(res.text).to.contain('' + server.url + '/video-channels/channel1') + expect(res.text).to.contain('' + server.url + '/video-channels/channel2') + + expect(res.text).to.contain('' + server.url + '/accounts/user1') + expect(res.text).to.contain('' + server.url + '/accounts/user2') + }) + + it('Should not fail with big title/description videos', async function () { + const name = 'v'.repeat(115) + + await server.videos.upload({ attributes: { name, description: 'd'.repeat(2500), nsfw: false } }) + + const res = await makeGetRequest({ + url: server.url, + path: '/sitemap.xml?t=2', // avoid using cache + expectedStatus: HttpStatusCode.OK_200 + }) + + await expectLogDoesNotContain(server, 'Warning in sitemap generation') + await expectLogDoesNotContain(server, 'Error in sitemap generation') + + expect(res.text).to.contain(`${'v'.repeat(97)}...`) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/peertube-runner/client-cli.ts b/packages/tests/src/peertube-runner/client-cli.ts new file mode 100644 index 000000000..814b7f13a --- /dev/null +++ b/packages/tests/src/peertube-runner/client-cli.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test peertube-runner program client CLI', function () { + let server: PeerTubeServer + let peertubeRunner: PeerTubeRunnerProcess + + before(async function () { + this.timeout(120_000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableRemoteTranscoding() + + peertubeRunner = new PeerTubeRunnerProcess(server) + await peertubeRunner.runServer() + }) + + it('Should not have PeerTube instance listed', async function () { + const data = await peertubeRunner.listRegisteredPeerTubeInstances() + + expect(data).to.not.contain(server.url) + }) + + it('Should register a new PeerTube instance', async function () { + const registrationToken = await server.runnerRegistrationTokens.getFirstRegistrationToken() + + await peertubeRunner.registerPeerTubeInstance({ + registrationToken, + runnerName: 'my super runner', + runnerDescription: 'super description' + }) + }) + + it('Should list this new PeerTube instance', async function () { + const data = await peertubeRunner.listRegisteredPeerTubeInstances() + + expect(data).to.contain(server.url) + expect(data).to.contain('my super runner') + expect(data).to.contain('super description') + }) + + it('Should still have the configuration after a restart', async function () { + peertubeRunner.kill() + + await peertubeRunner.runServer() + }) + + it('Should unregister the PeerTube instance', async function () { + await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'my super runner' }) + }) + + it('Should not have PeerTube instance listed', async function () { + const data = await peertubeRunner.listRegisteredPeerTubeInstances() + + expect(data).to.not.contain(server.url) + }) + + after(async function () { + peertubeRunner.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/peertube-runner/index.ts b/packages/tests/src/peertube-runner/index.ts new file mode 100644 index 000000000..29f21694f --- /dev/null +++ b/packages/tests/src/peertube-runner/index.ts @@ -0,0 +1,4 @@ +export * from './client-cli.js' +export * from './live-transcoding.js' +export * from './studio-transcoding.js' +export * from './vod-transcoding.js' diff --git a/packages/tests/src/peertube-runner/live-transcoding.ts b/packages/tests/src/peertube-runner/live-transcoding.ts new file mode 100644 index 000000000..9351bc5e2 --- /dev/null +++ b/packages/tests/src/peertube-runner/live-transcoding.ts @@ -0,0 +1,200 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + findExternalSavedVideo, + makeRawRequest, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs, + waitUntilLivePublishedOnAllServers, + waitUntilLiveWaitingOnAllServers +} from '@peertube/peertube-server-commands' +import { expectStartWith } from '@tests/shared/checks.js' +import { checkPeerTubeRunnerCacheIsEmpty } from '@tests/shared/directories.js' +import { testLiveVideoResolutions } from '@tests/shared/live.js' +import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js' +import { SQLCommand } from '@tests/shared/sql-command.js' + +describe('Test Live transcoding in peertube-runner program', function () { + let servers: PeerTubeServer[] = [] + let peertubeRunner: PeerTubeRunnerProcess + let sqlCommandServer1: SQLCommand + + function runSuite (options: { + objectStorage?: ObjectStorageCommand + } = {}) { + const { objectStorage } = options + + it('Should enable transcoding without additional resolutions', async function () { + this.timeout(120000) + + const { video } = await servers[0].live.quickCreate({ permanentLive: true, saveReplay: false, privacy: VideoPrivacy.PUBLIC }) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: video.uuid }) + await waitUntilLivePublishedOnAllServers(servers, video.uuid) + await waitJobs(servers) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId: video.uuid, + resolutions: [ 720, 480, 360, 240, 144 ], + objectStorage, + transcoded: true + }) + + await stopFfmpeg(ffmpegCommand) + + await waitUntilLiveWaitingOnAllServers(servers, video.uuid) + await servers[0].videos.remove({ id: video.id }) + }) + + it('Should transcode audio only RTMP stream', async function () { + this.timeout(120000) + + const { video } = await servers[0].live.quickCreate({ permanentLive: true, saveReplay: false, privacy: VideoPrivacy.UNLISTED }) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: video.uuid, fixtureName: 'video_short_no_audio.mp4' }) + await waitUntilLivePublishedOnAllServers(servers, video.uuid) + await waitJobs(servers) + + await stopFfmpeg(ffmpegCommand) + + await waitUntilLiveWaitingOnAllServers(servers, video.uuid) + await servers[0].videos.remove({ id: video.id }) + }) + + it('Should save a replay', async function () { + this.timeout(240000) + + const { video } = await servers[0].live.quickCreate({ permanentLive: true, saveReplay: true }) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: video.uuid }) + await waitUntilLivePublishedOnAllServers(servers, video.uuid) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId: video.uuid, + resolutions: [ 720, 480, 360, 240, 144 ], + objectStorage, + transcoded: true + }) + + await stopFfmpeg(ffmpegCommand) + + await waitUntilLiveWaitingOnAllServers(servers, video.uuid) + await waitJobs(servers) + + const session = await servers[0].live.findLatestSession({ videoId: video.uuid }) + expect(session.endingProcessed).to.be.true + expect(session.endDate).to.exist + expect(session.saveReplay).to.be.true + + const videoLiveDetails = await servers[0].videos.get({ id: video.uuid }) + const replay = await findExternalSavedVideo(servers[0], videoLiveDetails) + + for (const server of servers) { + const video = await server.videos.get({ id: replay.uuid }) + + expect(video.files).to.have.lengthOf(0) + expect(video.streamingPlaylists).to.have.lengthOf(1) + + const files = video.streamingPlaylists[0].files + expect(files).to.have.lengthOf(5) + + for (const file of files) { + if (objectStorage) { + expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + } + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + } + }) + } + + before(async function () { + this.timeout(120_000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await doubleFollow(servers[0], servers[1]) + + sqlCommandServer1 = new SQLCommand(servers[0]) + + await servers[0].config.enableRemoteTranscoding() + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + await servers[0].config.enableLive({ allowReplay: true, resolutions: 'max', transcoding: true }) + + const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken() + + peertubeRunner = new PeerTubeRunnerProcess(servers[0]) + await peertubeRunner.runServer() + await peertubeRunner.registerPeerTubeInstance({ registrationToken, runnerName: 'runner' }) + }) + + describe('With lives on local filesystem storage', function () { + + before(async function () { + await servers[0].config.enableTranscoding({ webVideo: true, hls: false, with0p: true }) + }) + + runSuite() + }) + + describe('With lives on object storage', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].kill() + + await servers[0].run(objectStorage.getDefaultMockConfig()) + + // Wait for peertube runner socket reconnection + await wait(1500) + }) + + runSuite({ objectStorage }) + + after(async function () { + await objectStorage.cleanupMock() + }) + }) + + describe('Check cleanup', function () { + + it('Should have an empty cache directory', async function () { + await checkPeerTubeRunnerCacheIsEmpty(peertubeRunner) + }) + }) + + after(async function () { + if (peertubeRunner) { + await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' }) + peertubeRunner.kill() + } + + if (sqlCommandServer1) await sqlCommandServer1.cleanup() + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/peertube-runner/studio-transcoding.ts b/packages/tests/src/peertube-runner/studio-transcoding.ts new file mode 100644 index 000000000..50e61091a --- /dev/null +++ b/packages/tests/src/peertube-runner/studio-transcoding.ts @@ -0,0 +1,127 @@ + +import { expect } from 'chai' +import { getAllFiles, wait } from '@peertube/peertube-core-utils' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + VideoStudioCommand, + waitJobs +} from '@peertube/peertube-server-commands' +import { expectStartWith, checkVideoDuration } from '@tests/shared/checks.js' +import { checkPeerTubeRunnerCacheIsEmpty } from '@tests/shared/directories.js' +import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js' + +describe('Test studio transcoding in peertube-runner program', function () { + let servers: PeerTubeServer[] = [] + let peertubeRunner: PeerTubeRunnerProcess + + function runSuite (options: { + objectStorage?: ObjectStorageCommand + } = {}) { + const { objectStorage } = options + + it('Should run a complex studio transcoding', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.mp4' }) + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: uuid }) + const oldFileUrls = getAllFiles(video).map(f => f.fileUrl) + + await servers[0].videoStudio.createEditionTasks({ videoId: uuid, tasks: VideoStudioCommand.getComplexTask() }) + await waitJobs(servers, { runnerJobs: true }) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + const files = getAllFiles(video) + + for (const f of files) { + expect(oldFileUrls).to.not.include(f.fileUrl) + } + + if (objectStorage) { + for (const webVideoFile of video.files) { + expectStartWith(webVideoFile.fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + + for (const hlsFile of video.streamingPlaylists[0].files) { + expectStartWith(hlsFile.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + } + } + + await checkVideoDuration(server, uuid, 9) + } + }) + } + + before(async function () { + this.timeout(120_000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + await servers[0].config.enableStudio() + await servers[0].config.enableRemoteStudio() + + const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken() + + peertubeRunner = new PeerTubeRunnerProcess(servers[0]) + await peertubeRunner.runServer() + await peertubeRunner.registerPeerTubeInstance({ registrationToken, runnerName: 'runner' }) + }) + + describe('With videos on local filesystem storage', function () { + runSuite() + }) + + describe('With videos on object storage', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].kill() + + await servers[0].run(objectStorage.getDefaultMockConfig()) + + // Wait for peertube runner socket reconnection + await wait(1500) + }) + + runSuite({ objectStorage }) + + after(async function () { + await objectStorage.cleanupMock() + }) + }) + + describe('Check cleanup', function () { + + it('Should have an empty cache directory', async function () { + await checkPeerTubeRunnerCacheIsEmpty(peertubeRunner) + }) + }) + + after(async function () { + if (peertubeRunner) { + await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' }) + peertubeRunner.kill() + } + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/peertube-runner/vod-transcoding.ts b/packages/tests/src/peertube-runner/vod-transcoding.ts new file mode 100644 index 000000000..ff5cefe36 --- /dev/null +++ b/packages/tests/src/peertube-runner/vod-transcoding.ts @@ -0,0 +1,349 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { expect } from 'chai' +import { getAllFiles, wait } from '@peertube/peertube-core-utils' +import { VideoPrivacy } from '@peertube/peertube-models' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { checkPeerTubeRunnerCacheIsEmpty } from '@tests/shared/directories.js' +import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js' +import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' +import { completeWebVideoFilesCheck } from '@tests/shared/videos.js' + +describe('Test VOD transcoding in peertube-runner program', function () { + let servers: PeerTubeServer[] = [] + let peertubeRunner: PeerTubeRunnerProcess + + function runSuite (options: { + webVideoEnabled: boolean + hlsEnabled: boolean + objectStorage?: ObjectStorageCommand + }) { + const { webVideoEnabled, hlsEnabled, objectStorage } = options + + const objectStorageBaseUrlWebVideo = objectStorage + ? objectStorage.getMockWebVideosBaseUrl() + : undefined + + const objectStorageBaseUrlHLS = objectStorage + ? objectStorage.getMockPlaylistBaseUrl() + : undefined + + it('Should upload a classic video mp4 and transcode it', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.mp4' }) + + await waitJobs(servers, { runnerJobs: true }) + + for (const server of servers) { + if (webVideoEnabled) { + await completeWebVideoFilesCheck({ + server, + originServer: servers[0], + fixture: 'video_short.mp4', + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlWebVideo, + files: [ + { resolution: 0 }, + { resolution: 144 }, + { resolution: 240 }, + { resolution: 360 }, + { resolution: 480 }, + { resolution: 720 } + ] + }) + } + + if (hlsEnabled) { + await completeCheckHlsPlaylist({ + hlsOnly: !webVideoEnabled, + servers, + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlHLS, + resolutions: [ 720, 480, 360, 240, 144, 0 ] + }) + } + } + }) + + it('Should upload a webm video and transcode it', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.webm' }) + + await waitJobs(servers, { runnerJobs: true }) + + for (const server of servers) { + if (webVideoEnabled) { + await completeWebVideoFilesCheck({ + server, + originServer: servers[0], + fixture: 'video_short.webm', + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlWebVideo, + files: [ + { resolution: 0 }, + { resolution: 144 }, + { resolution: 240 }, + { resolution: 360 }, + { resolution: 480 }, + { resolution: 720 } + ] + }) + } + + if (hlsEnabled) { + await completeCheckHlsPlaylist({ + hlsOnly: !webVideoEnabled, + servers, + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlHLS, + resolutions: [ 720, 480, 360, 240, 144, 0 ] + }) + } + } + }) + + it('Should upload an audio only video and transcode it', async function () { + this.timeout(120000) + + const attributes = { name: 'audio_without_preview', fixture: 'sample.ogg' } + const { uuid } = await servers[0].videos.upload({ attributes, mode: 'resumable' }) + + await waitJobs(servers, { runnerJobs: true }) + + for (const server of servers) { + if (webVideoEnabled) { + await completeWebVideoFilesCheck({ + server, + originServer: servers[0], + fixture: 'sample.ogg', + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlWebVideo, + files: [ + { resolution: 0 }, + { resolution: 144 }, + { resolution: 240 }, + { resolution: 360 }, + { resolution: 480 } + ] + }) + } + + if (hlsEnabled) { + await completeCheckHlsPlaylist({ + hlsOnly: !webVideoEnabled, + servers, + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlHLS, + resolutions: [ 480, 360, 240, 144, 0 ] + }) + } + } + }) + + it('Should upload a private video and transcode it', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.mp4', privacy: VideoPrivacy.PRIVATE }) + + await waitJobs(servers, { runnerJobs: true }) + + if (webVideoEnabled) { + await completeWebVideoFilesCheck({ + server: servers[0], + originServer: servers[0], + fixture: 'video_short.mp4', + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlWebVideo, + files: [ + { resolution: 0 }, + { resolution: 144 }, + { resolution: 240 }, + { resolution: 360 }, + { resolution: 480 }, + { resolution: 720 } + ] + }) + } + + if (hlsEnabled) { + await completeCheckHlsPlaylist({ + hlsOnly: !webVideoEnabled, + servers: [ servers[0] ], + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlHLS, + resolutions: [ 720, 480, 360, 240, 144, 0 ] + }) + } + }) + + it('Should transcode videos on manual run', async function () { + this.timeout(120000) + + await servers[0].config.disableTranscoding() + + const { uuid } = await servers[0].videos.quickUpload({ name: 'manual transcoding', fixture: 'video_short.mp4' }) + await waitJobs(servers, { runnerJobs: true }) + + { + const video = await servers[0].videos.get({ id: uuid }) + expect(getAllFiles(video)).to.have.lengthOf(1) + } + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + + await servers[0].videos.runTranscoding({ transcodingType: 'web-video', videoId: uuid }) + await waitJobs(servers, { runnerJobs: true }) + + await completeWebVideoFilesCheck({ + server: servers[0], + originServer: servers[0], + fixture: 'video_short.mp4', + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlWebVideo, + files: [ + { resolution: 0 }, + { resolution: 144 }, + { resolution: 240 }, + { resolution: 360 }, + { resolution: 480 }, + { resolution: 720 } + ] + }) + + await servers[0].videos.runTranscoding({ transcodingType: 'hls', videoId: uuid }) + await waitJobs(servers, { runnerJobs: true }) + + await completeCheckHlsPlaylist({ + hlsOnly: false, + servers: [ servers[0] ], + videoUUID: uuid, + objectStorageBaseUrl: objectStorageBaseUrlHLS, + resolutions: [ 720, 480, 360, 240, 144, 0 ] + }) + }) + } + + before(async function () { + this.timeout(120_000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableRemoteTranscoding() + + const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken() + + peertubeRunner = new PeerTubeRunnerProcess(servers[0]) + await peertubeRunner.runServer() + await peertubeRunner.registerPeerTubeInstance({ registrationToken, runnerName: 'runner' }) + }) + + describe('With videos on local filesystem storage', function () { + + describe('Web video only enabled', function () { + + before(async function () { + await servers[0].config.enableTranscoding({ webVideo: true, hls: false, with0p: true }) + }) + + runSuite({ webVideoEnabled: true, hlsEnabled: false }) + }) + + describe('HLS videos only enabled', function () { + + before(async function () { + await servers[0].config.enableTranscoding({ webVideo: false, hls: true, with0p: true }) + }) + + runSuite({ webVideoEnabled: false, hlsEnabled: true }) + }) + + describe('Web video & HLS enabled', function () { + + before(async function () { + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + }) + + runSuite({ webVideoEnabled: true, hlsEnabled: true }) + }) + }) + + describe('With videos on object storage', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].kill() + + await servers[0].run(objectStorage.getDefaultMockConfig()) + + // Wait for peertube runner socket reconnection + await wait(1500) + }) + + describe('Web video only enabled', function () { + + before(async function () { + await servers[0].config.enableTranscoding({ webVideo: true, hls: false, with0p: true }) + }) + + runSuite({ webVideoEnabled: true, hlsEnabled: false, objectStorage }) + }) + + describe('HLS videos only enabled', function () { + + before(async function () { + await servers[0].config.enableTranscoding({ webVideo: false, hls: true, with0p: true }) + }) + + runSuite({ webVideoEnabled: false, hlsEnabled: true, objectStorage }) + }) + + describe('Web video & HLS enabled', function () { + + before(async function () { + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + }) + + runSuite({ webVideoEnabled: true, hlsEnabled: true, objectStorage }) + }) + + after(async function () { + await objectStorage.cleanupMock() + }) + }) + + describe('Check cleanup', function () { + + it('Should have an empty cache directory', async function () { + await checkPeerTubeRunnerCacheIsEmpty(peertubeRunner) + }) + }) + + after(async function () { + if (peertubeRunner) { + await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' }) + peertubeRunner.kill() + } + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/plugins/action-hooks.ts b/packages/tests/src/plugins/action-hooks.ts new file mode 100644 index 000000000..136c7671b --- /dev/null +++ b/packages/tests/src/plugins/action-hooks.ts @@ -0,0 +1,298 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { ServerHookName, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + killallServers, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test plugin action hooks', function () { + let servers: PeerTubeServer[] + let videoUUID: string + let threadId: number + + function checkHook (hook: ServerHookName, strictCount = true, count = 1) { + return servers[0].servers.waitUntilLog('Run hook ' + hook, count, strictCount) + } + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath() }) + + await killallServers([ servers[0] ]) + + await servers[0].run({ + live: { + enabled: true + } + }) + + await servers[0].config.enableFileUpdate() + + await doubleFollow(servers[0], servers[1]) + }) + + describe('Application hooks', function () { + it('Should run action:application.listening', async function () { + await checkHook('action:application.listening') + }) + }) + + describe('Videos hooks', function () { + + it('Should run action:api.video.uploaded', async function () { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video' } }) + videoUUID = uuid + + await checkHook('action:api.video.uploaded') + }) + + it('Should run action:api.video.updated', async function () { + await servers[0].videos.update({ id: videoUUID, attributes: { name: 'video updated' } }) + + await checkHook('action:api.video.updated') + }) + + it('Should run action:api.video.viewed', async function () { + await servers[0].views.simulateView({ id: videoUUID }) + + await checkHook('action:api.video.viewed') + }) + + it('Should run action:api.video.file-updated', async function () { + await servers[0].videos.replaceSourceFile({ videoId: videoUUID, fixture: 'video_short.mp4' }) + + await checkHook('action:api.video.file-updated') + }) + + it('Should run action:api.video.deleted', async function () { + await servers[0].videos.remove({ id: videoUUID }) + + await checkHook('action:api.video.deleted') + }) + + after(async function () { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) + videoUUID = uuid + }) + }) + + describe('Video channel hooks', function () { + const channelName = 'my_super_channel' + + it('Should run action:api.video-channel.created', async function () { + await servers[0].channels.create({ attributes: { name: channelName } }) + + await checkHook('action:api.video-channel.created') + }) + + it('Should run action:api.video-channel.updated', async function () { + await servers[0].channels.update({ channelName, attributes: { displayName: 'my display name' } }) + + await checkHook('action:api.video-channel.updated') + }) + + it('Should run action:api.video-channel.deleted', async function () { + await servers[0].channels.delete({ channelName }) + + await checkHook('action:api.video-channel.deleted') + }) + }) + + describe('Live hooks', function () { + + it('Should run action:api.live-video.created', async function () { + const attributes = { + name: 'live', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[0].store.channel.id + } + + await servers[0].live.create({ fields: attributes }) + + await checkHook('action:api.live-video.created') + }) + + it('Should run action:live.video.state.updated', async function () { + this.timeout(60000) + + const attributes = { + name: 'live', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[0].store.channel.id + } + + const { uuid: liveVideoId } = await servers[0].live.create({ fields: attributes }) + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoId }) + await servers[0].live.waitUntilPublished({ videoId: liveVideoId }) + await waitJobs(servers) + + await checkHook('action:live.video.state.updated', true, 1) + + await stopFfmpeg(ffmpegCommand) + await servers[0].live.waitUntilEnded({ videoId: liveVideoId }) + await waitJobs(servers) + + await checkHook('action:live.video.state.updated', true, 2) + }) + }) + + describe('Comments hooks', function () { + it('Should run action:api.video-thread.created', async function () { + const created = await servers[0].comments.createThread({ videoId: videoUUID, text: 'thread' }) + threadId = created.id + + await checkHook('action:api.video-thread.created') + }) + + it('Should run action:api.video-comment-reply.created', async function () { + await servers[0].comments.addReply({ videoId: videoUUID, toCommentId: threadId, text: 'reply' }) + + await checkHook('action:api.video-comment-reply.created') + }) + + it('Should run action:api.video-comment.deleted', async function () { + await servers[0].comments.delete({ videoId: videoUUID, commentId: threadId }) + + await checkHook('action:api.video-comment.deleted') + }) + }) + + describe('Captions hooks', function () { + it('Should run action:api.video-caption.created', async function () { + await servers[0].captions.add({ videoId: videoUUID, language: 'en', fixture: 'subtitle-good.srt' }) + + await checkHook('action:api.video-caption.created') + }) + + it('Should run action:api.video-caption.deleted', async function () { + await servers[0].captions.delete({ videoId: videoUUID, language: 'en' }) + + await checkHook('action:api.video-caption.deleted') + }) + }) + + describe('Users hooks', function () { + let userId: number + + it('Should run action:api.user.registered', async function () { + await servers[0].registrations.register({ username: 'registered_user' }) + + await checkHook('action:api.user.registered') + }) + + it('Should run action:api.user.created', async function () { + const user = await servers[0].users.create({ username: 'created_user' }) + userId = user.id + + await checkHook('action:api.user.created') + }) + + it('Should run action:api.user.oauth2-got-token', async function () { + await servers[0].login.login({ user: { username: 'created_user' } }) + + await checkHook('action:api.user.oauth2-got-token') + }) + + it('Should run action:api.user.blocked', async function () { + await servers[0].users.banUser({ userId }) + + await checkHook('action:api.user.blocked') + }) + + it('Should run action:api.user.unblocked', async function () { + await servers[0].users.unbanUser({ userId }) + + await checkHook('action:api.user.unblocked') + }) + + it('Should run action:api.user.updated', async function () { + await servers[0].users.update({ userId, videoQuota: 50 }) + + await checkHook('action:api.user.updated') + }) + + it('Should run action:api.user.deleted', async function () { + await servers[0].users.remove({ userId }) + + await checkHook('action:api.user.deleted') + }) + }) + + describe('Playlist hooks', function () { + let playlistId: number + let videoId: number + + before(async function () { + { + const { id } = await servers[0].playlists.create({ + attributes: { + displayName: 'My playlist', + privacy: VideoPlaylistPrivacy.PRIVATE + } + }) + playlistId = id + } + + { + const { id } = await servers[0].videos.upload({ attributes: { name: 'my super name' } }) + videoId = id + } + }) + + it('Should run action:api.video-playlist-element.created', async function () { + await servers[0].playlists.addElement({ playlistId, attributes: { videoId } }) + + await checkHook('action:api.video-playlist-element.created') + }) + }) + + describe('Notification hook', function () { + + it('Should run action:notifier.notification.created', async function () { + await checkHook('action:notifier.notification.created', false) + }) + }) + + describe('Activity Pub hooks', function () { + let videoUUID: string + + it('Should run action:activity-pub.remote-video.created', async function () { + this.timeout(30000) + + const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' }) + videoUUID = uuid + + await servers[0].servers.waitUntilLog('action:activity-pub.remote-video.created - AP remote video - video remote video') + }) + + it('Should run action:activity-pub.remote-video.updated', async function () { + this.timeout(30000) + + await servers[1].videos.update({ id: videoUUID, attributes: { name: 'remote video updated' } }) + + await servers[0].servers.waitUntilLog( + 'action:activity-pub.remote-video.updated - AP remote video updated - video remote video updated', + 1, + false + ) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/plugins/external-auth.ts b/packages/tests/src/plugins/external-auth.ts new file mode 100644 index 000000000..c7fe22185 --- /dev/null +++ b/packages/tests/src/plugins/external-auth.ts @@ -0,0 +1,436 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, HttpStatusCodeType, UserAdminFlag, UserRole } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + decodeQueryString, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +async function loginExternal (options: { + server: PeerTubeServer + npmName: string + authName: string + username: string + query?: any + expectedStatus?: HttpStatusCodeType + expectedStatusStep2?: HttpStatusCodeType +}) { + const res = await options.server.plugins.getExternalAuth({ + npmName: options.npmName, + npmVersion: '0.0.1', + authName: options.authName, + query: options.query, + expectedStatus: options.expectedStatus || HttpStatusCode.FOUND_302 + }) + + if (res.status !== HttpStatusCode.FOUND_302) return + + const location = res.header.location + const { externalAuthToken } = decodeQueryString(location) + + const resLogin = await options.server.login.loginUsingExternalToken({ + username: options.username, + externalAuthToken: externalAuthToken as string, + expectedStatus: options.expectedStatusStep2 + }) + + return resLogin.body +} + +describe('Test external auth plugins', function () { + let server: PeerTubeServer + + let cyanAccessToken: string + let cyanRefreshToken: string + + let kefkaAccessToken: string + let kefkaRefreshToken: string + let kefkaId: number + + let externalAuthToken: string + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, { + rates_limit: { + login: { + max: 30 + } + } + }) + + await setAccessTokensToServers([ server ]) + + for (const suffix of [ 'one', 'two', 'three' ]) { + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-external-auth-' + suffix) }) + } + }) + + it('Should display the correct configuration', async function () { + const config = await server.config.getConfig() + + const auths = config.plugin.registeredExternalAuths + expect(auths).to.have.lengthOf(9) + + const auth2 = auths.find((a) => a.authName === 'external-auth-2') + expect(auth2).to.exist + expect(auth2.authDisplayName).to.equal('External Auth 2') + expect(auth2.npmName).to.equal('peertube-plugin-test-external-auth-one') + }) + + it('Should redirect for a Cyan login', async function () { + const res = await server.plugins.getExternalAuth({ + npmName: 'test-external-auth-one', + npmVersion: '0.0.1', + authName: 'external-auth-1', + query: { + username: 'cyan' + }, + expectedStatus: HttpStatusCode.FOUND_302 + }) + + const location = res.header.location + expect(location.startsWith('/login?')).to.be.true + + const searchParams = decodeQueryString(location) + + expect(searchParams.externalAuthToken).to.exist + expect(searchParams.username).to.equal('cyan') + + externalAuthToken = searchParams.externalAuthToken as string + }) + + it('Should reject auto external login with a missing or invalid token', async function () { + const command = server.login + + await command.loginUsingExternalToken({ username: 'cyan', externalAuthToken: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.loginUsingExternalToken({ username: 'cyan', externalAuthToken: 'blabla', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should reject auto external login with a missing or invalid username', async function () { + const command = server.login + + await command.loginUsingExternalToken({ username: '', externalAuthToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.loginUsingExternalToken({ username: '', externalAuthToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should reject auto external login with an expired token', async function () { + this.timeout(15000) + + await wait(5000) + + await server.login.loginUsingExternalToken({ + username: 'cyan', + externalAuthToken, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await server.servers.waitUntilLog('expired external auth token', 4) + }) + + it('Should auto login Cyan, create the user and use the token', async function () { + { + const res = await loginExternal({ + server, + npmName: 'test-external-auth-one', + authName: 'external-auth-1', + query: { + username: 'cyan' + }, + username: 'cyan' + }) + + cyanAccessToken = res.access_token + cyanRefreshToken = res.refresh_token + } + + { + const body = await server.users.getMyInfo({ token: cyanAccessToken }) + expect(body.username).to.equal('cyan') + expect(body.account.displayName).to.equal('cyan') + expect(body.email).to.equal('cyan@example.com') + expect(body.role.id).to.equal(UserRole.USER) + expect(body.adminFlags).to.equal(UserAdminFlag.NONE) + expect(body.videoQuota).to.equal(5242880) + expect(body.videoQuotaDaily).to.equal(-1) + } + }) + + it('Should auto login Kefka, create the user and use the token', async function () { + { + const res = await loginExternal({ + server, + npmName: 'test-external-auth-one', + authName: 'external-auth-2', + username: 'kefka' + }) + + kefkaAccessToken = res.access_token + kefkaRefreshToken = res.refresh_token + } + + { + const body = await server.users.getMyInfo({ token: kefkaAccessToken }) + expect(body.username).to.equal('kefka') + expect(body.account.displayName).to.equal('Kefka Palazzo') + expect(body.email).to.equal('kefka@example.com') + expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) + expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST) + expect(body.videoQuota).to.equal(42000) + expect(body.videoQuotaDaily).to.equal(42100) + + kefkaId = body.id + } + }) + + it('Should refresh Cyan token, but not Kefka token', async function () { + { + const resRefresh = await server.login.refreshToken({ refreshToken: cyanRefreshToken }) + cyanAccessToken = resRefresh.body.access_token + cyanRefreshToken = resRefresh.body.refresh_token + + const body = await server.users.getMyInfo({ token: cyanAccessToken }) + expect(body.username).to.equal('cyan') + } + + { + await server.login.refreshToken({ refreshToken: kefkaRefreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + }) + + it('Should update Cyan profile', async function () { + await server.users.updateMe({ + token: cyanAccessToken, + displayName: 'Cyan Garamonde', + description: 'Retainer to the king of Doma' + }) + + const body = await server.users.getMyInfo({ token: cyanAccessToken }) + expect(body.account.displayName).to.equal('Cyan Garamonde') + expect(body.account.description).to.equal('Retainer to the king of Doma') + }) + + it('Should logout Cyan', async function () { + await server.login.logout({ token: cyanAccessToken }) + }) + + it('Should have logged out Cyan', async function () { + await server.servers.waitUntilLog('On logout cyan') + + await server.users.getMyInfo({ token: cyanAccessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should login Cyan and keep the old existing profile', async function () { + { + const res = await loginExternal({ + server, + npmName: 'test-external-auth-one', + authName: 'external-auth-1', + query: { + username: 'cyan' + }, + username: 'cyan' + }) + + cyanAccessToken = res.access_token + } + + const body = await server.users.getMyInfo({ token: cyanAccessToken }) + expect(body.username).to.equal('cyan') + expect(body.account.displayName).to.equal('Cyan Garamonde') + expect(body.account.description).to.equal('Retainer to the king of Doma') + expect(body.role.id).to.equal(UserRole.USER) + }) + + it('Should login Kefka and update the profile', async function () { + { + await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 }) + await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' }) + + const body = await server.users.getMyInfo({ token: kefkaAccessToken }) + expect(body.username).to.equal('kefka') + expect(body.account.displayName).to.equal('kefka updated') + expect(body.videoQuota).to.equal(43000) + expect(body.videoQuotaDaily).to.equal(43100) + } + + { + const res = await loginExternal({ + server, + npmName: 'test-external-auth-one', + authName: 'external-auth-2', + username: 'kefka' + }) + + kefkaAccessToken = res.access_token + kefkaRefreshToken = res.refresh_token + + const body = await server.users.getMyInfo({ token: kefkaAccessToken }) + expect(body.username).to.equal('kefka') + expect(body.account.displayName).to.equal('Kefka Palazzo') + expect(body.videoQuota).to.equal(42000) + expect(body.videoQuotaDaily).to.equal(43100) + } + }) + + it('Should not update an external auth email', async function () { + await server.users.updateMe({ + token: cyanAccessToken, + email: 'toto@example.com', + currentPassword: 'toto', + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should reject token of Kefka by the plugin hook', async function () { + await wait(5000) + + await server.users.getMyInfo({ token: kefkaAccessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should unregister external-auth-2 and do not login existing Kefka', async function () { + await server.plugins.updateSettings({ + npmName: 'peertube-plugin-test-external-auth-one', + settings: { disableKefka: true } + }) + + await server.login.login({ user: { username: 'kefka', password: 'fake' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + await loginExternal({ + server, + npmName: 'test-external-auth-one', + authName: 'external-auth-2', + query: { + username: 'kefka' + }, + username: 'kefka', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should have disabled this auth', async function () { + const config = await server.config.getConfig() + + const auths = config.plugin.registeredExternalAuths + expect(auths).to.have.lengthOf(8) + + const auth1 = auths.find(a => a.authName === 'external-auth-2') + expect(auth1).to.not.exist + }) + + it('Should uninstall the plugin one and do not login Cyan', async function () { + await server.plugins.uninstall({ npmName: 'peertube-plugin-test-external-auth-one' }) + + await loginExternal({ + server, + npmName: 'test-external-auth-one', + authName: 'external-auth-1', + query: { + username: 'cyan' + }, + username: 'cyan', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + + await server.login.login({ user: { username: 'cyan', password: null }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.login.login({ user: { username: 'cyan', password: '' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.login.login({ user: { username: 'cyan', password: 'fake' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not login kefka with another plugin', async function () { + await loginExternal({ + server, + npmName: 'test-external-auth-two', + authName: 'external-auth-4', + username: 'kefka2', + expectedStatusStep2: HttpStatusCode.BAD_REQUEST_400 + }) + + await loginExternal({ + server, + npmName: 'test-external-auth-two', + authName: 'external-auth-4', + username: 'kefka', + expectedStatusStep2: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should not login an existing user email', async function () { + await server.users.create({ username: 'existing_user', password: 'super_password' }) + + await loginExternal({ + server, + npmName: 'test-external-auth-two', + authName: 'external-auth-6', + username: 'existing_user', + expectedStatusStep2: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should be able to login an existing user username and channel', async function () { + await server.users.create({ username: 'existing_user2' }) + await server.users.create({ username: 'existing_user2-1_channel' }) + + // Test twice to ensure we don't generate a username on every login + for (let i = 0; i < 2; i++) { + const res = await loginExternal({ + server, + npmName: 'test-external-auth-two', + authName: 'external-auth-7', + username: 'existing_user2' + }) + + const token = res.access_token + + const myInfo = await server.users.getMyInfo({ token }) + expect(myInfo.username).to.equal('existing_user2-1') + + expect(myInfo.videoChannels[0].name).to.equal('existing_user2-1_channel-1') + } + }) + + it('Should display the correct configuration', async function () { + const config = await server.config.getConfig() + + const auths = config.plugin.registeredExternalAuths + expect(auths).to.have.lengthOf(7) + + const auth2 = auths.find((a) => a.authName === 'external-auth-2') + expect(auth2).to.not.exist + }) + + after(async function () { + await cleanupTests([ server ]) + }) + + it('Should forward the redirectUrl if the plugin returns one', async function () { + const resLogin = await loginExternal({ + server, + npmName: 'test-external-auth-three', + authName: 'external-auth-7', + username: 'cid' + }) + + const { redirectUrl } = await server.login.logout({ token: resLogin.access_token }) + expect(redirectUrl).to.equal('https://example.com/redirectUrl') + }) + + it('Should call the plugin\'s onLogout method with the request', async function () { + const resLogin = await loginExternal({ + server, + npmName: 'test-external-auth-three', + authName: 'external-auth-8', + username: 'cid' + }) + + const { redirectUrl } = await server.login.logout({ token: resLogin.access_token }) + expect(redirectUrl).to.equal('https://example.com/redirectUrl?access_token=' + resLogin.access_token) + }) +}) diff --git a/packages/tests/src/plugins/filter-hooks.ts b/packages/tests/src/plugins/filter-hooks.ts new file mode 100644 index 000000000..88cfee631 --- /dev/null +++ b/packages/tests/src/plugins/filter-hooks.ts @@ -0,0 +1,909 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + HttpStatusCode, + PeerTubeProblemDocument, + VideoDetails, + VideoImportState, + VideoPlaylist, + VideoPlaylistPrivacy, + VideoPrivacy +} from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeActivityPubGetRequest, + makeGetRequest, + makeRawRequest, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { FIXTURE_URLS } from '../shared/tests.js' + +describe('Test plugin filter hooks', function () { + let servers: PeerTubeServer[] + let videoUUID: string + let threadId: number + let videoPlaylistUUID: string + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await doubleFollow(servers[0], servers[1]) + + await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath() }) + await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-filter-translations') }) + { + ({ uuid: videoPlaylistUUID } = await servers[0].playlists.create({ + attributes: { + displayName: 'my super playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + description: 'my super description', + videoChannelId: servers[0].store.channel.id + } + })) + } + + for (let i = 0; i < 10; i++) { + const video = await servers[0].videos.upload({ attributes: { name: 'default video ' + i } }) + await servers[0].playlists.addElement({ playlistId: videoPlaylistUUID, attributes: { videoId: video.id } }) + } + + const { data } = await servers[0].videos.list() + videoUUID = data[0].uuid + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { enabled: true }, + signup: { enabled: true }, + videoFile: { + update: { + enabled: true + } + }, + import: { + videos: { + http: { enabled: true }, + torrent: { enabled: true } + } + } + } + }) + + // Root subscribes to itself + await servers[0].subscriptions.add({ targetUri: 'root_channel@' + servers[0].host }) + }) + + describe('Videos', function () { + + it('Should run filter:api.videos.list.params', async function () { + const { data } = await servers[0].videos.list({ start: 0, count: 2 }) + + // 2 plugins do +1 to the count parameter + expect(data).to.have.lengthOf(4) + }) + + it('Should run filter:api.videos.list.result', async function () { + const { total } = await servers[0].videos.list({ start: 0, count: 0 }) + + // Plugin do +1 to the total result + expect(total).to.equal(11) + }) + + it('Should run filter:api.video-playlist.videos.list.params', async function () { + const { data } = await servers[0].playlists.listVideos({ + count: 2, + playlistId: videoPlaylistUUID + }) + + // 1 plugin do +1 to the count parameter + expect(data).to.have.lengthOf(3) + }) + + it('Should run filter:api.video-playlist.videos.list.result', async function () { + const { total } = await servers[0].playlists.listVideos({ + count: 0, + playlistId: videoPlaylistUUID + }) + + // Plugin do +1 to the total result + expect(total).to.equal(11) + }) + + it('Should run filter:api.accounts.videos.list.params', async function () { + const { data } = await servers[0].videos.listByAccount({ handle: 'root', start: 0, count: 2 }) + + // 1 plugin do +1 to the count parameter + expect(data).to.have.lengthOf(3) + }) + + it('Should run filter:api.accounts.videos.list.result', async function () { + const { total } = await servers[0].videos.listByAccount({ handle: 'root', start: 0, count: 2 }) + + // Plugin do +2 to the total result + expect(total).to.equal(12) + }) + + it('Should run filter:api.video-channels.videos.list.params', async function () { + const { data } = await servers[0].videos.listByChannel({ handle: 'root_channel', start: 0, count: 2 }) + + // 1 plugin do +3 to the count parameter + expect(data).to.have.lengthOf(5) + }) + + it('Should run filter:api.video-channels.videos.list.result', async function () { + const { total } = await servers[0].videos.listByChannel({ handle: 'root_channel', start: 0, count: 2 }) + + // Plugin do +3 to the total result + expect(total).to.equal(13) + }) + + it('Should run filter:api.user.me.videos.list.params', async function () { + const { data } = await servers[0].videos.listMyVideos({ start: 0, count: 2 }) + + // 1 plugin do +4 to the count parameter + expect(data).to.have.lengthOf(6) + }) + + it('Should run filter:api.user.me.videos.list.result', async function () { + const { total } = await servers[0].videos.listMyVideos({ start: 0, count: 2 }) + + // Plugin do +4 to the total result + expect(total).to.equal(14) + }) + + it('Should run filter:api.user.me.subscription-videos.list.params', async function () { + const { data } = await servers[0].videos.listMySubscriptionVideos({ start: 0, count: 2 }) + + // 1 plugin do +1 to the count parameter + expect(data).to.have.lengthOf(3) + }) + + it('Should run filter:api.user.me.subscription-videos.list.result', async function () { + const { total } = await servers[0].videos.listMySubscriptionVideos({ start: 0, count: 2 }) + + // Plugin do +4 to the total result + expect(total).to.equal(14) + }) + + it('Should run filter:api.video.get.result', async function () { + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.name).to.contain('<3') + }) + }) + + describe('Video/live/import accept', function () { + + it('Should run filter:api.video.upload.accept.result', async function () { + const options = { attributes: { name: 'video with bad word' }, expectedStatus: HttpStatusCode.FORBIDDEN_403 } + await servers[0].videos.upload({ mode: 'legacy', ...options }) + await servers[0].videos.upload({ mode: 'resumable', ...options }) + }) + + it('Should run filter:api.video.update-file.accept.result', async function () { + const res = await servers[0].videos.replaceSourceFile({ + videoId: videoUUID, + fixture: 'video_short1.webm', + completedExpectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + + expect((res as any)?.error).to.equal('no webm') + }) + + it('Should run filter:api.live-video.create.accept.result', async function () { + const attributes = { + name: 'video with bad word', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[0].store.channel.id + } + + await servers[0].live.create({ fields: attributes, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should run filter:api.video.pre-import-url.accept.result', async function () { + const attributes = { + name: 'normal title', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[0].store.channel.id, + targetUrl: FIXTURE_URLS.goodVideo + 'bad' + } + await servers[0].imports.importVideo({ attributes, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should run filter:api.video.pre-import-torrent.accept.result', async function () { + const attributes = { + name: 'bad torrent', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[0].store.channel.id, + torrentfile: 'video-720p.torrent' as any + } + await servers[0].imports.importVideo({ attributes, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should run filter:api.video.post-import-url.accept.result', async function () { + this.timeout(60000) + + let videoImportId: number + + { + const attributes = { + name: 'title with bad word', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[0].store.channel.id, + targetUrl: FIXTURE_URLS.goodVideo + } + const body = await servers[0].imports.importVideo({ attributes }) + videoImportId = body.id + } + + await waitJobs(servers) + + { + const body = await servers[0].imports.getMyVideoImports() + const videoImports = body.data + + const videoImport = videoImports.find(i => i.id === videoImportId) + + expect(videoImport.state.id).to.equal(VideoImportState.REJECTED) + expect(videoImport.state.label).to.equal('Rejected') + } + }) + + it('Should run filter:api.video.post-import-torrent.accept.result', async function () { + this.timeout(60000) + + let videoImportId: number + + { + const attributes = { + name: 'title with bad word', + privacy: VideoPrivacy.PUBLIC, + channelId: servers[0].store.channel.id, + torrentfile: 'video-720p.torrent' as any + } + const body = await servers[0].imports.importVideo({ attributes }) + videoImportId = body.id + } + + await waitJobs(servers) + + { + const { data: videoImports } = await servers[0].imports.getMyVideoImports() + + const videoImport = videoImports.find(i => i.id === videoImportId) + + expect(videoImport.state.id).to.equal(VideoImportState.REJECTED) + expect(videoImport.state.label).to.equal('Rejected') + } + }) + }) + + describe('Video comments accept', function () { + + it('Should run filter:api.video-thread.create.accept.result', async function () { + await servers[0].comments.createThread({ + videoId: videoUUID, + text: 'comment with bad word', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should run filter:api.video-comment-reply.create.accept.result', async function () { + const created = await servers[0].comments.createThread({ videoId: videoUUID, text: 'thread' }) + threadId = created.id + + await servers[0].comments.addReply({ + videoId: videoUUID, + toCommentId: threadId, + text: 'comment with bad word', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + await servers[0].comments.addReply({ + videoId: videoUUID, + toCommentId: threadId, + text: 'comment with good word', + expectedStatus: HttpStatusCode.OK_200 + }) + }) + + it('Should run filter:activity-pub.remote-video-comment.create.accept.result on a thread creation', async function () { + this.timeout(30000) + + await servers[1].comments.createThread({ videoId: videoUUID, text: 'comment with bad word' }) + + await waitJobs(servers) + + { + const thread = await servers[0].comments.listThreads({ videoId: videoUUID }) + expect(thread.data).to.have.lengthOf(1) + expect(thread.data[0].text).to.not.include(' bad ') + } + + { + const thread = await servers[1].comments.listThreads({ videoId: videoUUID }) + expect(thread.data).to.have.lengthOf(2) + } + }) + + it('Should run filter:activity-pub.remote-video-comment.create.accept.result on a reply creation', async function () { + this.timeout(30000) + + const { data } = await servers[1].comments.listThreads({ videoId: videoUUID }) + const threadIdServer2 = data.find(t => t.text === 'thread').id + + await servers[1].comments.addReply({ + videoId: videoUUID, + toCommentId: threadIdServer2, + text: 'comment with bad word' + }) + + await waitJobs(servers) + + { + const tree = await servers[0].comments.getThread({ videoId: videoUUID, threadId }) + expect(tree.children).to.have.lengthOf(1) + expect(tree.children[0].comment.text).to.not.include(' bad ') + } + + { + const tree = await servers[1].comments.getThread({ videoId: videoUUID, threadId: threadIdServer2 }) + expect(tree.children).to.have.lengthOf(2) + } + }) + }) + + describe('Video comments', function () { + + it('Should run filter:api.video-threads.list.params', async function () { + const { data } = await servers[0].comments.listThreads({ videoId: videoUUID, start: 0, count: 0 }) + + // our plugin do +1 to the count parameter + expect(data).to.have.lengthOf(1) + }) + + it('Should run filter:api.video-threads.list.result', async function () { + const { total } = await servers[0].comments.listThreads({ videoId: videoUUID, start: 0, count: 0 }) + + // Plugin do +1 to the total result + expect(total).to.equal(2) + }) + + it('Should run filter:api.video-thread-comments.list.params') + + it('Should run filter:api.video-thread-comments.list.result', async function () { + const thread = await servers[0].comments.getThread({ videoId: videoUUID, threadId }) + + expect(thread.comment.text.endsWith(' <3')).to.be.true + }) + + it('Should run filter:api.overviews.videos.list.{params,result}', async function () { + await servers[0].overviews.getVideos({ page: 1 }) + + // 3 because we get 3 samples per page + await servers[0].servers.waitUntilLog('Run hook filter:api.overviews.videos.list.params', 3) + await servers[0].servers.waitUntilLog('Run hook filter:api.overviews.videos.list.result', 3) + }) + }) + + describe('filter:video.auto-blacklist.result', function () { + + async function checkIsBlacklisted (id: number | string, value: boolean) { + const video = await servers[0].videos.getWithToken({ id }) + expect(video.blacklisted).to.equal(value) + } + + it('Should blacklist on upload', async function () { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video please blacklist me' } }) + await checkIsBlacklisted(uuid, true) + }) + + it('Should blacklist on import', async function () { + this.timeout(15000) + + const attributes = { + name: 'video please blacklist me', + targetUrl: FIXTURE_URLS.goodVideo, + channelId: servers[0].store.channel.id + } + const body = await servers[0].imports.importVideo({ attributes }) + await checkIsBlacklisted(body.video.uuid, true) + }) + + it('Should blacklist on update', async function () { + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video' } }) + await checkIsBlacklisted(uuid, false) + + await servers[0].videos.update({ id: uuid, attributes: { name: 'please blacklist me' } }) + await checkIsBlacklisted(uuid, true) + }) + + it('Should blacklist on remote upload', async function () { + this.timeout(120000) + + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'remote please blacklist me' } }) + await waitJobs(servers) + + await checkIsBlacklisted(uuid, true) + }) + + it('Should blacklist on remote update', async function () { + this.timeout(120000) + + const { uuid } = await servers[1].videos.upload({ attributes: { name: 'video' } }) + await waitJobs(servers) + + await checkIsBlacklisted(uuid, false) + + await servers[1].videos.update({ id: uuid, attributes: { name: 'please blacklist me' } }) + await waitJobs(servers) + + await checkIsBlacklisted(uuid, true) + }) + }) + + describe('Should run filter:api.user.signup.allowed.result', function () { + + before(async function () { + await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: false } } }) + }) + + it('Should run on config endpoint', async function () { + const body = await servers[0].config.getConfig() + expect(body.signup.allowed).to.be.true + }) + + it('Should allow a signup', async function () { + await servers[0].registrations.register({ username: 'john1' }) + }) + + it('Should not allow a signup', async function () { + const res = await servers[0].registrations.register({ + username: 'jma 1', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + + expect(res.body.error).to.equal('No jma 1') + }) + }) + + describe('Should run filter:api.user.request-signup.allowed.result', function () { + + before(async function () { + await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: true } } }) + }) + + it('Should run on config endpoint', async function () { + const body = await servers[0].config.getConfig() + expect(body.signup.allowed).to.be.true + }) + + it('Should allow a signup request', async function () { + await servers[0].registrations.requestRegistration({ username: 'john2', registrationReason: 'tt' }) + }) + + it('Should not allow a signup request', async function () { + const body = await servers[0].registrations.requestRegistration({ + username: 'jma 2', + registrationReason: 'tt', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + + expect((body as unknown as PeerTubeProblemDocument).error).to.equal('No jma 2') + }) + }) + + describe('Download hooks', function () { + const downloadVideos: VideoDetails[] = [] + let downloadVideo2Token: string + + before(async function () { + this.timeout(120000) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + transcoding: { + webVideos: { + enabled: true + }, + hls: { + enabled: true + } + } + } + }) + + const uuids: string[] = [] + + for (const name of [ 'bad torrent', 'bad file', 'bad playlist file' ]) { + const uuid = (await servers[0].videos.quickUpload({ name })).uuid + uuids.push(uuid) + } + + await waitJobs(servers) + + for (const uuid of uuids) { + downloadVideos.push(await servers[0].videos.get({ id: uuid })) + } + + downloadVideo2Token = await servers[0].videoToken.getVideoFileToken({ videoId: downloadVideos[2].uuid }) + }) + + it('Should run filter:api.download.torrent.allowed.result', async function () { + const res = await makeRawRequest({ url: downloadVideos[0].files[0].torrentDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + expect(res.body.error).to.equal('Liu Bei') + + await makeRawRequest({ url: downloadVideos[1].files[0].torrentDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: downloadVideos[2].files[0].torrentDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should run filter:api.download.video.allowed.result', async function () { + { + const refused = downloadVideos[1].files[0].fileDownloadUrl + const allowed = [ + downloadVideos[0].files[0].fileDownloadUrl, + downloadVideos[2].files[0].fileDownloadUrl + ] + + const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + expect(res.body.error).to.equal('Cao Cao') + + for (const url of allowed) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + { + const refused = downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl + + const allowed = [ + downloadVideos[2].files[0].fileDownloadUrl, + downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, + downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl + ] + + // Only streaming playlist is refuse + const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + expect(res.body.error).to.equal('Sun Jian') + + // But not we there is a user in res + await makeRawRequest({ url: refused, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: refused, query: { videoFileToken: downloadVideo2Token }, expectedStatus: HttpStatusCode.OK_200 }) + + // Other files work + for (const url of allowed) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) + } + } + }) + }) + + describe('Embed filters', function () { + const embedVideos: VideoDetails[] = [] + const embedPlaylists: VideoPlaylist[] = [] + + before(async function () { + this.timeout(60000) + + await servers[0].config.disableTranscoding() + + for (const name of [ 'bad embed', 'good embed' ]) { + { + const uuid = (await servers[0].videos.quickUpload({ name })).uuid + embedVideos.push(await servers[0].videos.get({ id: uuid })) + } + + { + const attributes = { displayName: name, videoChannelId: servers[0].store.channel.id, privacy: VideoPlaylistPrivacy.PUBLIC } + const { id } = await servers[0].playlists.create({ attributes }) + + const playlist = await servers[0].playlists.get({ playlistId: id }) + embedPlaylists.push(playlist) + } + } + }) + + it('Should run filter:html.embed.video.allowed.result', async function () { + const res = await makeGetRequest({ url: servers[0].url, path: embedVideos[0].embedPath, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.text).to.equal('Lu Bu') + }) + + it('Should run filter:html.embed.video-playlist.allowed.result', async function () { + const res = await makeGetRequest({ url: servers[0].url, path: embedPlaylists[0].embedPath, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.text).to.equal('Diao Chan') + }) + }) + + describe('Client HTML filters', function () { + let videoUUID: string + + before(async function () { + this.timeout(60000) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'html video' }) + videoUUID = uuid + }) + + it('Should run filter:html.client.json-ld.result', async function () { + const res = await makeGetRequest({ url: servers[0].url, path: '/w/' + videoUUID, expectedStatus: HttpStatusCode.OK_200 }) + expect(res.text).to.contain('"recordedAt":"http://example.com/recordedAt"') + }) + + it('Should not run filter:html.client.json-ld.result with an account', async function () { + const res = await makeGetRequest({ url: servers[0].url, path: '/a/root', expectedStatus: HttpStatusCode.OK_200 }) + expect(res.text).not.to.contain('"recordedAt":"http://example.com/recordedAt"') + }) + }) + + describe('Search filters', function () { + + before(async function () { + await servers[0].config.updateCustomSubConfig({ + newConfig: { + search: { + searchIndex: { + enabled: true, + isDefaultSearch: false, + disableLocalSearch: false + } + } + } + }) + }) + + it('Should run filter:api.search.videos.local.list.{params,result}', async function () { + await servers[0].search.advancedVideoSearch({ + search: { + search: 'Sun Quan' + } + }) + + await servers[0].servers.waitUntilLog('Run hook filter:api.search.videos.local.list.params', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.videos.local.list.result', 1) + }) + + it('Should run filter:api.search.videos.index.list.{params,result}', async function () { + await servers[0].search.advancedVideoSearch({ + search: { + search: 'Sun Quan', + searchTarget: 'search-index' + } + }) + + await servers[0].servers.waitUntilLog('Run hook filter:api.search.videos.local.list.params', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.videos.local.list.result', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.videos.index.list.params', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.videos.index.list.result', 1) + }) + + it('Should run filter:api.search.video-channels.local.list.{params,result}', async function () { + await servers[0].search.advancedChannelSearch({ + search: { + search: 'Sun Ce' + } + }) + + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-channels.local.list.params', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-channels.local.list.result', 1) + }) + + it('Should run filter:api.search.video-channels.index.list.{params,result}', async function () { + await servers[0].search.advancedChannelSearch({ + search: { + search: 'Sun Ce', + searchTarget: 'search-index' + } + }) + + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-channels.local.list.params', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-channels.local.list.result', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-channels.index.list.params', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-channels.index.list.result', 1) + }) + + it('Should run filter:api.search.video-playlists.local.list.{params,result}', async function () { + await servers[0].search.advancedPlaylistSearch({ + search: { + search: 'Sun Jian' + } + }) + + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-playlists.local.list.params', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-playlists.local.list.result', 1) + }) + + it('Should run filter:api.search.video-playlists.index.list.{params,result}', async function () { + await servers[0].search.advancedPlaylistSearch({ + search: { + search: 'Sun Jian', + searchTarget: 'search-index' + } + }) + + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-playlists.local.list.params', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-playlists.local.list.result', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-playlists.index.list.params', 1) + await servers[0].servers.waitUntilLog('Run hook filter:api.search.video-playlists.index.list.result', 1) + }) + }) + + describe('Upload/import/live attributes filters', function () { + + before(async function () { + await servers[0].config.enableLive({ transcoding: false, allowReplay: false }) + await servers[0].config.enableImports() + await servers[0].config.disableTranscoding() + }) + + it('Should run filter:api.video.upload.video-attribute.result', async function () { + for (const mode of [ 'legacy' as 'legacy', 'resumable' as 'resumable' ]) { + const { id } = await servers[0].videos.upload({ attributes: { name: 'video', description: 'upload' }, mode }) + + const video = await servers[0].videos.get({ id }) + expect(video.description).to.equal('upload - filter:api.video.upload.video-attribute.result') + } + }) + + it('Should run filter:api.video.import-url.video-attribute.result', async function () { + const attributes = { + name: 'video', + description: 'import url', + channelId: servers[0].store.channel.id, + targetUrl: FIXTURE_URLS.goodVideo, + privacy: VideoPrivacy.PUBLIC + } + const { video: { id } } = await servers[0].imports.importVideo({ attributes }) + + const video = await servers[0].videos.get({ id }) + expect(video.description).to.equal('import url - filter:api.video.import-url.video-attribute.result') + }) + + it('Should run filter:api.video.import-torrent.video-attribute.result', async function () { + const attributes = { + name: 'video', + description: 'import torrent', + channelId: servers[0].store.channel.id, + magnetUri: FIXTURE_URLS.magnet, + privacy: VideoPrivacy.PUBLIC + } + const { video: { id } } = await servers[0].imports.importVideo({ attributes }) + + const video = await servers[0].videos.get({ id }) + expect(video.description).to.equal('import torrent - filter:api.video.import-torrent.video-attribute.result') + }) + + it('Should run filter:api.video.live.video-attribute.result', async function () { + const fields = { + name: 'live', + description: 'live', + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { id } = await servers[0].live.create({ fields }) + + const video = await servers[0].videos.get({ id }) + expect(video.description).to.equal('live - filter:api.video.live.video-attribute.result') + }) + }) + + describe('Stats filters', function () { + + it('Should run filter:api.server.stats.get.result', async function () { + const data = await servers[0].stats.get() + + expect((data as any).customStats).to.equal(14) + }) + + }) + + describe('Job queue filters', function () { + let videoUUID: string + + before(async function () { + this.timeout(120_000) + + await servers[0].config.enableMinimumTranscoding() + const { uuid } = await servers[0].videos.quickUpload({ name: 'studio' }) + + const video = await servers[0].videos.get({ id: uuid }) + expect(video.duration).at.least(2) + videoUUID = video.uuid + + await waitJobs(servers) + + await servers[0].config.enableStudio() + }) + + it('Should run filter:job-queue.process.params', async function () { + this.timeout(120_000) + + await servers[0].videoStudio.createEditionTasks({ + videoId: videoUUID, + tasks: [ + { + name: 'add-intro', + options: { + file: 'video_very_short_240p.mp4' + } + } + ] + }) + + await waitJobs(servers) + + await servers[0].servers.waitUntilLog('Run hook filter:job-queue.process.params', 1, false) + + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.duration).at.most(2) + }) + + it('Should run filter:job-queue.process.result', async function () { + await servers[0].servers.waitUntilLog('Run hook filter:job-queue.process.result', 1, false) + }) + }) + + describe('Transcoding filters', async function () { + + it('Should run filter:transcoding.auto.resolutions-to-transcode.result', async function () { + const { uuid } = await servers[0].videos.quickUpload({ name: 'transcode-filter' }) + + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: uuid }) + expect(video.files).to.have.lengthOf(2) + expect(video.files.find(f => f.resolution.id === 100 as any)).to.exist + }) + }) + + describe('Video channel filters', async function () { + + it('Should run filter:api.video-channels.list.params', async function () { + const { data } = await servers[0].channels.list({ start: 0, count: 0 }) + + // plugin do +1 to the count parameter + expect(data).to.have.lengthOf(1) + }) + + it('Should run filter:api.video-channels.list.result', async function () { + const { total } = await servers[0].channels.list({ start: 0, count: 1 }) + + // plugin do +1 to the total parameter + expect(total).to.equal(4) + }) + + it('Should run filter:api.video-channel.get.result', async function () { + const channel = await servers[0].channels.get({ channelName: 'root_channel' }) + expect(channel.displayName).to.equal('Main root channel <3') + }) + }) + + describe('Activity Pub', function () { + + it('Should run filter:activity-pub.activity.context.build.result', async function () { + const { body } = await makeActivityPubGetRequest(servers[0].url, '/w/' + videoUUID) + expect(body.type).to.equal('Video') + + expect(body['@context'].some(c => { + return typeof c === 'object' && c.recordedAt === 'https://schema.org/recordedAt' + })).to.be.true + }) + + it('Should run filter:activity-pub.video.json-ld.build.result', async function () { + const { body } = await makeActivityPubGetRequest(servers[0].url, '/w/' + videoUUID) + expect(body.name).to.equal('default video 0') + expect(body.videoName).to.equal('default video 0') + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/plugins/html-injection.ts b/packages/tests/src/plugins/html-injection.ts new file mode 100644 index 000000000..269a45b98 --- /dev/null +++ b/packages/tests/src/plugins/html-injection.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createSingleServer, + makeHTMLRequest, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test plugins HTML injection', function () { + let server: PeerTubeServer = null + let command: PluginsCommand + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + command = server.plugins + }) + + it('Should not inject global css file in HTML', async function () { + { + const text = await command.getCSS() + expect(text).to.be.empty + } + + for (const path of [ '/', '/videos/embed/1', '/video-playlists/embed/1' ]) { + const res = await makeHTMLRequest(server.url, path) + expect(res.text).to.not.include('link rel="stylesheet" href="/plugins/global.css') + } + }) + + it('Should install a plugin and a theme', async function () { + this.timeout(30000) + + await command.install({ npmName: 'peertube-plugin-hello-world' }) + }) + + it('Should have the correct global css', async function () { + { + const text = await command.getCSS() + expect(text).to.contain('background-color: red') + } + + for (const path of [ '/', '/videos/embed/1', '/video-playlists/embed/1' ]) { + const res = await makeHTMLRequest(server.url, path) + expect(res.text).to.include('link rel="stylesheet" href="/plugins/global.css') + } + }) + + it('Should have an empty global css on uninstall', async function () { + await command.uninstall({ npmName: 'peertube-plugin-hello-world' }) + + { + const text = await command.getCSS() + expect(text).to.be.empty + } + + for (const path of [ '/', '/videos/embed/1', '/video-playlists/embed/1' ]) { + const res = await makeHTMLRequest(server.url, path) + expect(res.text).to.not.include('link rel="stylesheet" href="/plugins/global.css') + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/plugins/id-and-pass-auth.ts b/packages/tests/src/plugins/id-and-pass-auth.ts new file mode 100644 index 000000000..a332f0eec --- /dev/null +++ b/packages/tests/src/plugins/id-and-pass-auth.ts @@ -0,0 +1,248 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, UserRole } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test id and pass auth plugins', function () { + let server: PeerTubeServer + + let crashAccessToken: string + let crashRefreshToken: string + + let lagunaAccessToken: string + let lagunaRefreshToken: string + let lagunaId: number + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + for (const suffix of [ 'one', 'two', 'three' ]) { + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-id-pass-auth-' + suffix) }) + } + }) + + it('Should display the correct configuration', async function () { + const config = await server.config.getConfig() + + const auths = config.plugin.registeredIdAndPassAuths + expect(auths).to.have.lengthOf(8) + + const crashAuth = auths.find(a => a.authName === 'crash-auth') + expect(crashAuth).to.exist + expect(crashAuth.npmName).to.equal('peertube-plugin-test-id-pass-auth-one') + expect(crashAuth.weight).to.equal(50) + }) + + it('Should not login', async function () { + await server.login.login({ user: { username: 'toto', password: 'password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should login Spyro, create the user and use the token', async function () { + const accessToken = await server.login.getAccessToken({ username: 'spyro', password: 'spyro password' }) + + const body = await server.users.getMyInfo({ token: accessToken }) + + expect(body.username).to.equal('spyro') + expect(body.account.displayName).to.equal('Spyro the Dragon') + expect(body.role.id).to.equal(UserRole.USER) + }) + + it('Should login Crash, create the user and use the token', async function () { + { + const body = await server.login.login({ user: { username: 'crash', password: 'crash password' } }) + crashAccessToken = body.access_token + crashRefreshToken = body.refresh_token + } + + { + const body = await server.users.getMyInfo({ token: crashAccessToken }) + + expect(body.username).to.equal('crash') + expect(body.account.displayName).to.equal('Crash Bandicoot') + expect(body.role.id).to.equal(UserRole.MODERATOR) + } + }) + + it('Should login the first Laguna, create the user and use the token', async function () { + { + const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } }) + lagunaAccessToken = body.access_token + lagunaRefreshToken = body.refresh_token + } + + { + const body = await server.users.getMyInfo({ token: lagunaAccessToken }) + + expect(body.username).to.equal('laguna') + expect(body.account.displayName).to.equal('Laguna Loire') + expect(body.role.id).to.equal(UserRole.USER) + + lagunaId = body.id + } + }) + + it('Should refresh crash token, but not laguna token', async function () { + { + const resRefresh = await server.login.refreshToken({ refreshToken: crashRefreshToken }) + crashAccessToken = resRefresh.body.access_token + crashRefreshToken = resRefresh.body.refresh_token + + const body = await server.users.getMyInfo({ token: crashAccessToken }) + expect(body.username).to.equal('crash') + } + + { + await server.login.refreshToken({ refreshToken: lagunaRefreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + } + }) + + it('Should update Crash profile', async function () { + await server.users.updateMe({ + token: crashAccessToken, + displayName: 'Beautiful Crash', + description: 'Mutant eastern barred bandicoot' + }) + + const body = await server.users.getMyInfo({ token: crashAccessToken }) + + expect(body.account.displayName).to.equal('Beautiful Crash') + expect(body.account.description).to.equal('Mutant eastern barred bandicoot') + }) + + it('Should logout Crash', async function () { + await server.login.logout({ token: crashAccessToken }) + }) + + it('Should have logged out Crash', async function () { + await server.servers.waitUntilLog('On logout for auth 1 - 2') + + await server.users.getMyInfo({ token: crashAccessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should login Crash and keep the old existing profile', async function () { + crashAccessToken = await server.login.getAccessToken({ username: 'crash', password: 'crash password' }) + + const body = await server.users.getMyInfo({ token: crashAccessToken }) + + expect(body.username).to.equal('crash') + expect(body.account.displayName).to.equal('Beautiful Crash') + expect(body.account.description).to.equal('Mutant eastern barred bandicoot') + expect(body.role.id).to.equal(UserRole.MODERATOR) + }) + + it('Should login Laguna and update the profile', async function () { + { + await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 }) + await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' }) + + const body = await server.users.getMyInfo({ token: lagunaAccessToken }) + expect(body.username).to.equal('laguna') + expect(body.account.displayName).to.equal('laguna updated') + expect(body.videoQuota).to.equal(43000) + expect(body.videoQuotaDaily).to.equal(43100) + } + + { + const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } }) + lagunaAccessToken = body.access_token + lagunaRefreshToken = body.refresh_token + } + + { + const body = await server.users.getMyInfo({ token: lagunaAccessToken }) + expect(body.username).to.equal('laguna') + expect(body.account.displayName).to.equal('Laguna Loire') + expect(body.videoQuota).to.equal(42000) + expect(body.videoQuotaDaily).to.equal(43100) + } + }) + + it('Should reject token of laguna by the plugin hook', async function () { + await wait(5000) + + await server.users.getMyInfo({ token: lagunaAccessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should reject an invalid username, email, role or display name', async function () { + const command = server.login + + await command.login({ user: { username: 'ward', password: 'ward password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.servers.waitUntilLog('valid username') + + await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.servers.waitUntilLog('valid displayName') + + await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.servers.waitUntilLog('valid role') + + await command.login({ user: { username: 'ellone', password: 'elonne password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await server.servers.waitUntilLog('valid email') + }) + + it('Should unregister spyro-auth and do not login existing Spyro', async function () { + await server.plugins.updateSettings({ + npmName: 'peertube-plugin-test-id-pass-auth-one', + settings: { disableSpyro: true } + }) + + const command = server.login + await command.login({ user: { username: 'spyro', password: 'spyro password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await command.login({ user: { username: 'spyro', password: 'fake' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should have disabled this auth', async function () { + const config = await server.config.getConfig() + + const auths = config.plugin.registeredIdAndPassAuths + expect(auths).to.have.lengthOf(7) + + const spyroAuth = auths.find(a => a.authName === 'spyro-auth') + expect(spyroAuth).to.not.exist + }) + + it('Should uninstall the plugin one and do not login existing Crash', async function () { + await server.plugins.uninstall({ npmName: 'peertube-plugin-test-id-pass-auth-one' }) + + await server.login.login({ + user: { username: 'crash', password: 'crash password' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should display the correct configuration', async function () { + const config = await server.config.getConfig() + + const auths = config.plugin.registeredIdAndPassAuths + expect(auths).to.have.lengthOf(6) + + const crashAuth = auths.find(a => a.authName === 'crash-auth') + expect(crashAuth).to.not.exist + }) + + it('Should display plugin auth information in users list', async function () { + const { data } = await server.users.list() + + const root = data.find(u => u.username === 'root') + const crash = data.find(u => u.username === 'crash') + const laguna = data.find(u => u.username === 'laguna') + + expect(root.pluginAuth).to.be.null + expect(crash.pluginAuth).to.equal('peertube-plugin-test-id-pass-auth-one') + expect(laguna.pluginAuth).to.equal('peertube-plugin-test-id-pass-auth-two') + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/plugins/index.ts b/packages/tests/src/plugins/index.ts new file mode 100644 index 000000000..210af7236 --- /dev/null +++ b/packages/tests/src/plugins/index.ts @@ -0,0 +1,13 @@ +import './action-hooks' +import './external-auth' +import './filter-hooks' +import './html-injection' +import './id-and-pass-auth' +import './plugin-helpers' +import './plugin-router' +import './plugin-storage' +import './plugin-transcoding' +import './plugin-unloading' +import './plugin-websocket' +import './translations' +import './video-constants' diff --git a/packages/tests/src/plugins/plugin-helpers.ts b/packages/tests/src/plugins/plugin-helpers.ts new file mode 100644 index 000000000..d2bd8596e --- /dev/null +++ b/packages/tests/src/plugins/plugin-helpers.ts @@ -0,0 +1,383 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists } from 'fs-extra/esm' +import { HttpStatusCode, ThumbnailType } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + makePostBodyRequest, + makeRawRequest, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { checkVideoFilesWereRemoved } from '@tests/shared/videos.js' + +function postCommand (server: PeerTubeServer, command: string, bodyArg?: object) { + const body = { command } + if (bodyArg) Object.assign(body, bodyArg) + + return makePostBodyRequest({ + url: server.url, + path: '/plugins/test-four/router/commander', + fields: body, + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) +} + +describe('Test plugin helpers', function () { + let servers: PeerTubeServer[] + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-four') }) + }) + + describe('Logger', function () { + + it('Should have logged things', async function () { + await servers[0].servers.waitUntilLog(servers[0].host + ' peertube-plugin-test-four', 1, false) + await servers[0].servers.waitUntilLog('Hello world from plugin four', 1) + }) + }) + + describe('Database', function () { + + it('Should have made a query', async function () { + await servers[0].servers.waitUntilLog(`root email is admin${servers[0].internalServerNumber}@example.com`) + }) + }) + + describe('Config', function () { + + it('Should have the correct webserver url', async function () { + await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`) + }) + + it('Should have the correct listening config', async function () { + const res = await makeGetRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/server-listening-config', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.config).to.exist + expect(res.body.config.hostname).to.equal('::') + expect(res.body.config.port).to.equal(servers[0].port) + }) + + it('Should have the correct config', async function () { + const res = await makeGetRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/server-config', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.serverConfig).to.exist + expect(res.body.serverConfig.instance.name).to.equal('PeerTube') + }) + }) + + describe('Server', function () { + + it('Should get the server actor', async function () { + await servers[0].servers.waitUntilLog('server actor name is peertube') + }) + }) + + describe('Socket', function () { + + it('Should sendNotification without any exceptions', async () => { + const user = await servers[0].users.create({ username: 'notis_redding', password: 'secret1234?' }) + await makePostBodyRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/send-notification', + fields: { + userId: user.id + }, + expectedStatus: HttpStatusCode.CREATED_201 + }) + }) + + it('Should sendVideoLiveNewState without any exceptions', async () => { + const res = await servers[0].videos.quickUpload({ name: 'video server 1' }) + + await makePostBodyRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/send-video-live-new-state/' + res.uuid, + expectedStatus: HttpStatusCode.CREATED_201 + }) + + await servers[0].videos.remove({ id: res.uuid }) + }) + }) + + describe('Plugin', function () { + + it('Should get the base static route', async function () { + const res = await makeGetRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/static-route', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.staticRoute).to.equal('/plugins/test-four/0.0.1/static/') + }) + + it('Should get the base static route', async function () { + const baseRouter = '/plugins/test-four/0.0.1/router/' + + const res = await makeGetRequest({ + url: servers[0].url, + path: baseRouter + 'router-route', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.routerRoute).to.equal(baseRouter) + }) + }) + + describe('User', function () { + let rootId: number + + it('Should not get a user if not authenticated', async function () { + await makeGetRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/user', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should get a user if authenticated', async function () { + const res = await makeGetRequest({ + url: servers[0].url, + token: servers[0].accessToken, + path: '/plugins/test-four/router/user', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.username).to.equal('root') + expect(res.body.displayName).to.equal('root') + expect(res.body.isAdmin).to.be.true + expect(res.body.isModerator).to.be.false + expect(res.body.isUser).to.be.false + + rootId = res.body.id + }) + + it('Should load a user by id', async function () { + { + const res = await makeGetRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/user/' + rootId, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.username).to.equal('root') + } + + { + await makeGetRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/user/42', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + } + }) + }) + + describe('Moderation', function () { + let videoUUIDServer1: string + + before(async function () { + this.timeout(60000) + + { + const res = await servers[0].videos.quickUpload({ name: 'video server 1' }) + videoUUIDServer1 = res.uuid + } + + { + await servers[1].videos.quickUpload({ name: 'video server 2' }) + } + + await waitJobs(servers) + + const { data } = await servers[0].videos.list() + + expect(data).to.have.lengthOf(2) + }) + + it('Should mute server 2', async function () { + await postCommand(servers[0], 'blockServer', { hostToBlock: servers[1].host }) + + const { data } = await servers[0].videos.list() + + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('video server 1') + }) + + it('Should unmute server 2', async function () { + await postCommand(servers[0], 'unblockServer', { hostToUnblock: servers[1].host }) + + const { data } = await servers[0].videos.list() + + expect(data).to.have.lengthOf(2) + }) + + it('Should mute account of server 2', async function () { + await postCommand(servers[0], 'blockAccount', { handleToBlock: `root@${servers[1].host}` }) + + const { data } = await servers[0].videos.list() + + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('video server 1') + }) + + it('Should unmute account of server 2', async function () { + await postCommand(servers[0], 'unblockAccount', { handleToUnblock: `root@${servers[1].host}` }) + + const { data } = await servers[0].videos.list() + + expect(data).to.have.lengthOf(2) + }) + + it('Should blacklist video', async function () { + await postCommand(servers[0], 'blacklist', { videoUUID: videoUUIDServer1, unfederate: true }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('video server 2') + } + }) + + it('Should unblacklist video', async function () { + await postCommand(servers[0], 'unblacklist', { videoUUID: videoUUIDServer1 }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + expect(data).to.have.lengthOf(2) + } + }) + }) + + describe('Videos', function () { + let videoUUID: string + let videoPath: string + + before(async function () { + this.timeout(240000) + + await servers[0].config.enableTranscoding() + + const res = await servers[0].videos.quickUpload({ name: 'video1' }) + videoUUID = res.uuid + + await waitJobs(servers) + }) + + it('Should get video files', async function () { + const { body } = await makeGetRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/video-files/' + videoUUID, + expectedStatus: HttpStatusCode.OK_200 + }) + + // Video files check + { + expect(body.webVideo.videoFiles).to.be.an('array') + expect(body.hls.videoFiles).to.be.an('array') + + for (const resolution of [ 144, 240, 360, 480, 720 ]) { + for (const files of [ body.webVideo.videoFiles, body.hls.videoFiles ]) { + const file = files.find(f => f.resolution === resolution) + expect(file).to.exist + + expect(file.size).to.be.a('number') + expect(file.fps).to.equal(25) + + expect(await pathExists(file.path)).to.be.true + await makeRawRequest({ url: file.url, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + videoPath = body.webVideo.videoFiles[0].path + } + + // Thumbnails check + { + expect(body.thumbnails).to.be.an('array') + + const miniature = body.thumbnails.find(t => t.type === ThumbnailType.MINIATURE) + expect(miniature).to.exist + expect(await pathExists(miniature.path)).to.be.true + await makeRawRequest({ url: miniature.url, expectedStatus: HttpStatusCode.OK_200 }) + + const preview = body.thumbnails.find(t => t.type === ThumbnailType.PREVIEW) + expect(preview).to.exist + expect(await pathExists(preview.path)).to.be.true + await makeRawRequest({ url: preview.url, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should probe a file', async function () { + const { body } = await makeGetRequest({ + url: servers[0].url, + path: '/plugins/test-four/router/ffprobe', + query: { + path: videoPath + }, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(body.streams).to.be.an('array') + expect(body.streams).to.have.lengthOf(2) + }) + + it('Should remove a video after a view', async function () { + this.timeout(40000) + + // Should not throw -> video exists + const video = await servers[0].videos.get({ id: videoUUID }) + // Should delete the video + await servers[0].views.simulateView({ id: videoUUID }) + + await servers[0].servers.waitUntilLog('Video deleted by plugin four.') + + try { + // Should throw because the video should have been deleted + await servers[0].videos.get({ id: videoUUID }) + throw new Error('Video exists') + } catch (err) { + if (err.message.includes('exists')) throw err + } + + await checkVideoFilesWereRemoved({ server: servers[0], video }) + }) + + it('Should have fetched the video by URL', async function () { + await servers[0].servers.waitUntilLog(`video from DB uuid is ${videoUUID}`) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/plugins/plugin-router.ts b/packages/tests/src/plugins/plugin-router.ts new file mode 100644 index 000000000..6f3571c05 --- /dev/null +++ b/packages/tests/src/plugins/plugin-router.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + makePostBodyRequest, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { HttpStatusCode } from '@peertube/peertube-models' + +describe('Test plugin helpers', function () { + let server: PeerTubeServer + const basePaths = [ + '/plugins/test-five/router/', + '/plugins/test-five/0.0.1/router/' + ] + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-five') }) + }) + + it('Should answer "pong"', async function () { + for (const path of basePaths) { + const res = await makeGetRequest({ + url: server.url, + path: path + 'ping', + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.message).to.equal('pong') + } + }) + + it('Should check if authenticated', async function () { + for (const path of basePaths) { + const res = await makeGetRequest({ + url: server.url, + path: path + 'is-authenticated', + token: server.accessToken, + expectedStatus: 200 + }) + + expect(res.body.isAuthenticated).to.equal(true) + + const secRes = await makeGetRequest({ + url: server.url, + path: path + 'is-authenticated', + expectedStatus: 200 + }) + + expect(secRes.body.isAuthenticated).to.equal(false) + } + }) + + it('Should mirror post body', async function () { + const body = { + hello: 'world', + riri: 'fifi', + loulou: 'picsou' + } + + for (const path of basePaths) { + const res = await makePostBodyRequest({ + url: server.url, + path: path + 'form/post/mirror', + fields: body, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body).to.deep.equal(body) + } + }) + + it('Should remove the plugin and remove the routes', async function () { + await server.plugins.uninstall({ npmName: 'peertube-plugin-test-five' }) + + for (const path of basePaths) { + await makeGetRequest({ + url: server.url, + path: path + 'ping', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + + await makePostBodyRequest({ + url: server.url, + path: path + 'ping', + fields: {}, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/plugins/plugin-storage.ts b/packages/tests/src/plugins/plugin-storage.ts new file mode 100644 index 000000000..f9b0ead0c --- /dev/null +++ b/packages/tests/src/plugins/plugin-storage.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists } from 'fs-extra/esm' +import { readdir, readFile } from 'fs/promises' +import { join } from 'path' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test plugin storage', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-six') }) + }) + + describe('DB storage', function () { + it('Should correctly store a subkey', async function () { + await server.servers.waitUntilLog('superkey stored value is toto') + }) + + it('Should correctly retrieve an array as array from the storage.', async function () { + await server.servers.waitUntilLog('storedArrayKey isArray is true') + await server.servers.waitUntilLog('storedArrayKey stored value is toto, toto2') + }) + }) + + describe('Disk storage', function () { + let dataPath: string + let pluginDataPath: string + + async function getFileContent () { + const files = await readdir(pluginDataPath) + expect(files).to.have.lengthOf(1) + + return readFile(join(pluginDataPath, files[0]), 'utf8') + } + + before(function () { + dataPath = server.servers.buildDirectory('plugins/data') + pluginDataPath = join(dataPath, 'peertube-plugin-test-six') + }) + + it('Should have created the directory on install', async function () { + const dataPath = server.servers.buildDirectory('plugins/data') + const pluginDataPath = join(dataPath, 'peertube-plugin-test-six') + + expect(await pathExists(dataPath)).to.be.true + expect(await pathExists(pluginDataPath)).to.be.true + expect(await readdir(pluginDataPath)).to.have.lengthOf(0) + }) + + it('Should have created a file', async function () { + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path: '/plugins/test-six/router/create-file', + expectedStatus: HttpStatusCode.OK_200 + }) + + const content = await getFileContent() + expect(content).to.equal('Prince Ali') + }) + + it('Should still have the file after an uninstallation', async function () { + await server.plugins.uninstall({ npmName: 'peertube-plugin-test-six' }) + + const content = await getFileContent() + expect(content).to.equal('Prince Ali') + }) + + it('Should still have the file after the reinstallation', async function () { + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-six') }) + + const content = await getFileContent() + expect(content).to.equal('Prince Ali') + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/plugins/plugin-transcoding.ts b/packages/tests/src/plugins/plugin-transcoding.ts new file mode 100644 index 000000000..2f50f65ff --- /dev/null +++ b/packages/tests/src/plugins/plugin-transcoding.ts @@ -0,0 +1,279 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { getAudioStream, getVideoStream, getVideoStreamFPS } from '@peertube/peertube-ffmpeg' +import { VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers, + setDefaultVideoChannel, + testFfmpegStreamError, + waitJobs +} from '@peertube/peertube-server-commands' + +async function createLiveWrapper (server: PeerTubeServer) { + const liveAttributes = { + name: 'live video', + channelId: server.store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + + const { uuid } = await server.live.create({ fields: liveAttributes }) + + return uuid +} + +function updateConf (server: PeerTubeServer, vodProfile: string, liveProfile: string) { + return server.config.updateCustomSubConfig({ + newConfig: { + transcoding: { + enabled: true, + profile: vodProfile, + hls: { + enabled: true + }, + webVideos: { + enabled: true + }, + resolutions: { + '240p': true, + '360p': false, + '480p': false, + '720p': true + } + }, + live: { + transcoding: { + profile: liveProfile, + enabled: true, + resolutions: { + '240p': true, + '360p': false, + '480p': false, + '720p': true + } + } + } + } + }) +} + +describe('Test transcoding plugins', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(60000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await updateConf(server, 'default', 'default') + }) + + describe('When using a plugin adding profiles to existing encoders', function () { + + async function checkVideoFPS (uuid: string, type: 'above' | 'below', fps: number) { + const video = await server.videos.get({ id: uuid }) + const files = video.files.concat(...video.streamingPlaylists.map(p => p.files)) + + for (const file of files) { + if (type === 'above') { + expect(file.fps).to.be.above(fps) + } else { + expect(file.fps).to.be.below(fps) + } + } + } + + async function checkLiveFPS (uuid: string, type: 'above' | 'below', fps: number) { + const playlistUrl = `${server.url}/static/streaming-playlists/hls/${uuid}/0.m3u8` + const videoFPS = await getVideoStreamFPS(playlistUrl) + + if (type === 'above') { + expect(videoFPS).to.be.above(fps) + } else { + expect(videoFPS).to.be.below(fps) + } + } + + before(async function () { + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-transcoding-one') }) + }) + + it('Should have the appropriate available profiles', async function () { + const config = await server.config.getConfig() + + expect(config.transcoding.availableProfiles).to.have.members([ 'default', 'low-vod', 'input-options-vod', 'bad-scale-vod' ]) + expect(config.live.transcoding.availableProfiles).to.have.members([ 'default', 'high-live', 'input-options-live', 'bad-scale-live' ]) + }) + + describe('VOD', function () { + + it('Should not use the plugin profile if not chosen by the admin', async function () { + this.timeout(240000) + + const videoUUID = (await server.videos.quickUpload({ name: 'video' })).uuid + await waitJobs([ server ]) + + await checkVideoFPS(videoUUID, 'above', 20) + }) + + it('Should use the vod profile', async function () { + this.timeout(240000) + + await updateConf(server, 'low-vod', 'default') + + const videoUUID = (await server.videos.quickUpload({ name: 'video' })).uuid + await waitJobs([ server ]) + + await checkVideoFPS(videoUUID, 'below', 12) + }) + + it('Should apply input options in vod profile', async function () { + this.timeout(240000) + + await updateConf(server, 'input-options-vod', 'default') + + const videoUUID = (await server.videos.quickUpload({ name: 'video' })).uuid + await waitJobs([ server ]) + + await checkVideoFPS(videoUUID, 'below', 6) + }) + + it('Should apply the scale filter in vod profile', async function () { + this.timeout(240000) + + await updateConf(server, 'bad-scale-vod', 'default') + + const videoUUID = (await server.videos.quickUpload({ name: 'video' })).uuid + await waitJobs([ server ]) + + // Transcoding failed + const video = await server.videos.get({ id: videoUUID }) + expect(video.files).to.have.lengthOf(1) + expect(video.streamingPlaylists).to.have.lengthOf(0) + }) + }) + + describe('Live', function () { + + it('Should not use the plugin profile if not chosen by the admin', async function () { + this.timeout(240000) + + const liveVideoId = await createLiveWrapper(server) + + await server.live.sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_very_short_240p.mp4' }) + await server.live.waitUntilPublished({ videoId: liveVideoId }) + await waitJobs([ server ]) + + await checkLiveFPS(liveVideoId, 'above', 20) + }) + + it('Should use the live profile', async function () { + this.timeout(240000) + + await updateConf(server, 'low-vod', 'high-live') + + const liveVideoId = await createLiveWrapper(server) + + await server.live.sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_very_short_240p.mp4' }) + await server.live.waitUntilPublished({ videoId: liveVideoId }) + await waitJobs([ server ]) + + await checkLiveFPS(liveVideoId, 'above', 45) + }) + + it('Should apply the input options on live profile', async function () { + this.timeout(240000) + + await updateConf(server, 'low-vod', 'input-options-live') + + const liveVideoId = await createLiveWrapper(server) + + await server.live.sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_very_short_240p.mp4' }) + await server.live.waitUntilPublished({ videoId: liveVideoId }) + await waitJobs([ server ]) + + await checkLiveFPS(liveVideoId, 'above', 45) + }) + + it('Should apply the scale filter name on live profile', async function () { + this.timeout(240000) + + await updateConf(server, 'low-vod', 'bad-scale-live') + + const liveVideoId = await createLiveWrapper(server) + + const command = await server.live.sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_very_short_240p.mp4' }) + await testFfmpegStreamError(command, true) + }) + + it('Should default to the default profile if the specified profile does not exist', async function () { + this.timeout(240000) + + await server.plugins.uninstall({ npmName: 'peertube-plugin-test-transcoding-one' }) + + const config = await server.config.getConfig() + + expect(config.transcoding.availableProfiles).to.deep.equal([ 'default' ]) + expect(config.live.transcoding.availableProfiles).to.deep.equal([ 'default' ]) + + const videoUUID = (await server.videos.quickUpload({ name: 'video', fixture: 'video_very_short_240p.mp4' })).uuid + await waitJobs([ server ]) + + await checkVideoFPS(videoUUID, 'above', 20) + }) + }) + + }) + + describe('When using a plugin adding new encoders', function () { + + before(async function () { + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-transcoding-two') }) + + await updateConf(server, 'test-vod-profile', 'test-live-profile') + }) + + it('Should use the new vod encoders', async function () { + this.timeout(240000) + + const videoUUID = (await server.videos.quickUpload({ name: 'video', fixture: 'video_very_short_240p.mp4' })).uuid + await waitJobs([ server ]) + + const video = await server.videos.get({ id: videoUUID }) + + const path = server.servers.buildWebVideoFilePath(video.files[0].fileUrl) + const audioProbe = await getAudioStream(path) + expect(audioProbe.audioStream.codec_name).to.equal('opus') + + const videoProbe = await getVideoStream(path) + expect(videoProbe.codec_name).to.equal('vp9') + }) + + it('Should use the new live encoders', async function () { + this.timeout(240000) + + const liveVideoId = await createLiveWrapper(server) + + await server.live.sendRTMPStreamInVideo({ videoId: liveVideoId, fixtureName: 'video_short2.webm' }) + await server.live.waitUntilPublished({ videoId: liveVideoId }) + await waitJobs([ server ]) + + const playlistUrl = `${server.url}/static/streaming-playlists/hls/${liveVideoId}/0.m3u8` + const audioProbe = await getAudioStream(playlistUrl) + expect(audioProbe.audioStream.codec_name).to.equal('opus') + + const videoProbe = await getVideoStream(playlistUrl) + expect(videoProbe.codec_name).to.equal('h264') + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/plugins/plugin-unloading.ts b/packages/tests/src/plugins/plugin-unloading.ts new file mode 100644 index 000000000..70310bc8c --- /dev/null +++ b/packages/tests/src/plugins/plugin-unloading.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { HttpStatusCode } from '@peertube/peertube-models' + +describe('Test plugins module unloading', function () { + let server: PeerTubeServer = null + const requestPath = '/plugins/test-unloading/router/get' + let value: string = null + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-unloading') }) + }) + + it('Should return a numeric value', async function () { + const res = await makeGetRequest({ + url: server.url, + path: requestPath, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.message).to.match(/^\d+$/) + value = res.body.message + }) + + it('Should return the same value the second time', async function () { + const res = await makeGetRequest({ + url: server.url, + path: requestPath, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.message).to.be.equal(value) + }) + + it('Should uninstall the plugin and free the route', async function () { + await server.plugins.uninstall({ npmName: 'peertube-plugin-test-unloading' }) + + await makeGetRequest({ + url: server.url, + path: requestPath, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should return a different numeric value', async function () { + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-unloading') }) + + const res = await makeGetRequest({ + url: server.url, + path: requestPath, + expectedStatus: HttpStatusCode.OK_200 + }) + + expect(res.body.message).to.match(/^\d+$/) + expect(res.body.message).to.be.not.equal(value) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/plugins/plugin-websocket.ts b/packages/tests/src/plugins/plugin-websocket.ts new file mode 100644 index 000000000..832dcebd0 --- /dev/null +++ b/packages/tests/src/plugins/plugin-websocket.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import WebSocket from 'ws' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +function buildWebSocket (server: PeerTubeServer, path: string) { + return new WebSocket('ws://' + server.host + path) +} + +function expectErrorOrTimeout (server: PeerTubeServer, path: string, expectedTimeout: number) { + return new Promise((res, rej) => { + const ws = buildWebSocket(server, path) + ws.on('error', () => res()) + + const timeout = setTimeout(() => res(), expectedTimeout) + + ws.on('open', () => { + clearTimeout(timeout) + + return rej(new Error('Connect did not timeout')) + }) + }) +} + +describe('Test plugin websocket', function () { + let server: PeerTubeServer + const basePaths = [ + '/plugins/test-websocket/ws/', + '/plugins/test-websocket/0.0.1/ws/' + ] + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-websocket') }) + }) + + it('Should not connect to the websocket without the appropriate path', async function () { + const paths = [ + '/plugins/unknown/ws/', + '/plugins/unknown/0.0.1/ws/' + ] + + for (const path of paths) { + await expectErrorOrTimeout(server, path, 1000) + } + }) + + it('Should not connect to the websocket without the appropriate sub path', async function () { + for (const path of basePaths) { + await expectErrorOrTimeout(server, path + '/unknown', 1000) + } + }) + + it('Should connect to the websocket and receive pong', function (done) { + const ws = buildWebSocket(server, basePaths[0]) + + ws.on('open', () => ws.send('ping')) + ws.on('message', data => { + if (data.toString() === 'pong') return done() + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/plugins/translations.ts b/packages/tests/src/plugins/translations.ts new file mode 100644 index 000000000..a69e14134 --- /dev/null +++ b/packages/tests/src/plugins/translations.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test plugin translations', function () { + let server: PeerTubeServer + let command: PluginsCommand + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + command = server.plugins + + await command.install({ path: PluginsCommand.getPluginTestPath() }) + await command.install({ path: PluginsCommand.getPluginTestPath('-filter-translations') }) + }) + + it('Should not have translations for locale pt', async function () { + const body = await command.getTranslations({ locale: 'pt' }) + + expect(body).to.deep.equal({}) + }) + + it('Should have translations for locale fr', async function () { + const body = await command.getTranslations({ locale: 'fr-FR' }) + + expect(body).to.deep.equal({ + 'peertube-plugin-test': { + Hi: 'Coucou' + }, + 'peertube-plugin-test-filter-translations': { + 'Hello world': 'Bonjour le monde' + } + }) + }) + + it('Should have translations of locale it', async function () { + const body = await command.getTranslations({ locale: 'it-IT' }) + + expect(body).to.deep.equal({ + 'peertube-plugin-test-filter-translations': { + 'Hello world': 'Ciao, mondo!' + } + }) + }) + + it('Should remove the plugin and remove the locales', async function () { + await command.uninstall({ npmName: 'peertube-plugin-test-filter-translations' }) + + { + const body = await command.getTranslations({ locale: 'fr-FR' }) + + expect(body).to.deep.equal({ + 'peertube-plugin-test': { + Hi: 'Coucou' + } + }) + } + + { + const body = await command.getTranslations({ locale: 'it-IT' }) + + expect(body).to.deep.equal({}) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/plugins/video-constants.ts b/packages/tests/src/plugins/video-constants.ts new file mode 100644 index 000000000..b81240a64 --- /dev/null +++ b/packages/tests/src/plugins/video-constants.ts @@ -0,0 +1,180 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createSingleServer, + makeGetRequest, + PeerTubeServer, + PluginsCommand, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' +import { HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models' + +describe('Test plugin altering video constants', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-video-constants') }) + }) + + it('Should have updated languages', async function () { + const languages = await server.videos.getLanguages() + + expect(languages['en']).to.not.exist + expect(languages['fr']).to.not.exist + + expect(languages['al_bhed']).to.equal('Al Bhed') + expect(languages['al_bhed2']).to.equal('Al Bhed 2') + expect(languages['al_bhed3']).to.not.exist + }) + + it('Should have updated categories', async function () { + const categories = await server.videos.getCategories() + + expect(categories[1]).to.not.exist + expect(categories[2]).to.not.exist + + expect(categories[42]).to.equal('Best category') + expect(categories[43]).to.equal('High best category') + }) + + it('Should have updated licences', async function () { + const licences = await server.videos.getLicences() + + expect(licences[1]).to.not.exist + expect(licences[7]).to.not.exist + + expect(licences[42]).to.equal('Best licence') + expect(licences[43]).to.equal('High best licence') + }) + + it('Should have updated video privacies', async function () { + const privacies = await server.videos.getPrivacies() + + expect(privacies[1]).to.exist + expect(privacies[2]).to.not.exist + expect(privacies[3]).to.exist + expect(privacies[4]).to.exist + }) + + it('Should have updated playlist privacies', async function () { + const playlistPrivacies = await server.playlists.getPrivacies() + + expect(playlistPrivacies[1]).to.exist + expect(playlistPrivacies[2]).to.exist + expect(playlistPrivacies[3]).to.not.exist + }) + + it('Should not be able to create a video with this privacy', async function () { + const attributes = { name: 'video', privacy: VideoPrivacy.UNLISTED } + await server.videos.upload({ attributes, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not be able to create a video with this privacy', async function () { + const attributes = { displayName: 'video playlist', privacy: VideoPlaylistPrivacy.PRIVATE } + await server.playlists.create({ attributes, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should be able to upload a video with these values', async function () { + const attributes = { name: 'video', category: 42, licence: 42, language: 'al_bhed2' } + const { uuid } = await server.videos.upload({ attributes }) + + const video = await server.videos.get({ id: uuid }) + expect(video.language.label).to.equal('Al Bhed 2') + expect(video.licence.label).to.equal('Best licence') + expect(video.category.label).to.equal('Best category') + }) + + it('Should uninstall the plugin and reset languages, categories, licences and privacies', async function () { + await server.plugins.uninstall({ npmName: 'peertube-plugin-test-video-constants' }) + + { + const languages = await server.videos.getLanguages() + + expect(languages['en']).to.equal('English') + expect(languages['fr']).to.equal('French') + + expect(languages['al_bhed']).to.not.exist + expect(languages['al_bhed2']).to.not.exist + expect(languages['al_bhed3']).to.not.exist + } + + { + const categories = await server.videos.getCategories() + + expect(categories[1]).to.equal('Music') + expect(categories[2]).to.equal('Films') + + expect(categories[42]).to.not.exist + expect(categories[43]).to.not.exist + } + + { + const licences = await server.videos.getLicences() + + expect(licences[1]).to.equal('Attribution') + expect(licences[7]).to.equal('Public Domain Dedication') + + expect(licences[42]).to.not.exist + expect(licences[43]).to.not.exist + } + + { + const privacies = await server.videos.getPrivacies() + + expect(privacies[1]).to.exist + expect(privacies[2]).to.exist + expect(privacies[3]).to.exist + expect(privacies[4]).to.exist + } + + { + const playlistPrivacies = await server.playlists.getPrivacies() + + expect(playlistPrivacies[1]).to.exist + expect(playlistPrivacies[2]).to.exist + expect(playlistPrivacies[3]).to.exist + } + }) + + it('Should be able to reset categories', async function () { + await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-video-constants') }) + + { + const categories = await server.videos.getCategories() + + expect(categories[1]).to.not.exist + expect(categories[2]).to.not.exist + + expect(categories[42]).to.exist + expect(categories[43]).to.exist + } + + await makeGetRequest({ + url: server.url, + token: server.accessToken, + path: '/plugins/test-video-constants/router/reset-categories', + expectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + + { + const categories = await server.videos.getCategories() + + expect(categories[1]).to.exist + expect(categories[2]).to.exist + + expect(categories[42]).to.not.exist + expect(categories[43]).to.not.exist + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/server-helpers/activitypub.ts b/packages/tests/src/server-helpers/activitypub.ts new file mode 100644 index 000000000..dfcd0389f --- /dev/null +++ b/packages/tests/src/server-helpers/activitypub.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { signAndContextify } from '@peertube/peertube-server/server/helpers/activity-pub-utils.js' +import { + isHTTPSignatureVerified, + isJsonLDSignatureVerified, + parseHTTPSignature +} from '@peertube/peertube-server/server/helpers/peertube-crypto.js' +import { buildRequestStub } from '@tests/shared/tests.js' +import { expect } from 'chai' +import { readJsonSync } from 'fs-extra/esm' +import cloneDeep from 'lodash-es/cloneDeep.js' + +function fakeFilter () { + return (data: any) => Promise.resolve(data) +} + +describe('Test activity pub helpers', function () { + + describe('When checking the Linked Signature', function () { + + it('Should fail with an invalid Mastodon signature', async function () { + const body = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/create-bad-signature.json')) + const publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey + const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } + + const result = await isJsonLDSignatureVerified(fromActor as any, body) + + expect(result).to.be.false + }) + + it('Should fail with an invalid public key', async function () { + const body = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/create.json')) + const publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/bad-public-key.json')).publicKey + const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } + + const result = await isJsonLDSignatureVerified(fromActor as any, body) + + expect(result).to.be.false + }) + + it('Should succeed with a valid Mastodon signature', async function () { + const body = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/create.json')) + const publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey + const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } + + const result = await isJsonLDSignatureVerified(fromActor as any, body) + + expect(result).to.be.true + }) + + it('Should fail with an invalid PeerTube signature', async function () { + const keys = readJsonSync(buildAbsoluteFixturePath('./ap-json/peertube/invalid-keys.json')) + const body = readJsonSync(buildAbsoluteFixturePath('./ap-json/peertube/announce-without-context.json')) + + const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey } + const signedBody = await signAndContextify(actorSignature as any, body, 'Announce', fakeFilter()) + + const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' } + const result = await isJsonLDSignatureVerified(fromActor as any, signedBody) + + expect(result).to.be.false + }) + + it('Should succeed with a valid PeerTube signature', async function () { + const keys = readJsonSync(buildAbsoluteFixturePath('./ap-json/peertube/keys.json')) + const body = readJsonSync(buildAbsoluteFixturePath('./ap-json/peertube/announce-without-context.json')) + + const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey } + const signedBody = await signAndContextify(actorSignature as any, body, 'Announce', fakeFilter()) + + const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' } + const result = await isJsonLDSignatureVerified(fromActor as any, signedBody) + + expect(result).to.be.true + }) + }) + + describe('When checking HTTP signature', function () { + it('Should fail with an invalid http signature', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/bad-http-signature.json'))) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + + const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10) + const publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey + + const actor = { publicKey } + const verified = isHTTPSignatureVerified(parsed, actor as any) + + expect(verified).to.be.false + }) + + it('Should fail with an invalid public key', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/http-signature.json'))) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + + const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10) + const publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/bad-public-key.json')).publicKey + + const actor = { publicKey } + const verified = isHTTPSignatureVerified(parsed, actor as any) + + expect(verified).to.be.false + }) + + it('Should fail because of clock skew', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/http-signature.json'))) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + + let errored = false + try { + parseHTTPSignature(req) + } catch { + errored = true + } + + expect(errored).to.be.true + }) + + it('Should with a scheme', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/http-signature.json'))) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + req.headers = 'Signature ' + mastodonObject.headers + + let errored = false + try { + parseHTTPSignature(req, 3600 * 1000 * 365 * 10) + } catch { + errored = true + } + + expect(errored).to.be.true + }) + + it('Should succeed with a valid signature', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/http-signature.json'))) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + + const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10) + const publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey + + const actor = { publicKey } + const verified = isHTTPSignatureVerified(parsed, actor as any) + + expect(verified).to.be.true + }) + + }) + +}) diff --git a/packages/tests/src/server-helpers/core-utils.ts b/packages/tests/src/server-helpers/core-utils.ts new file mode 100644 index 000000000..06c78591e --- /dev/null +++ b/packages/tests/src/server-helpers/core-utils.ts @@ -0,0 +1,150 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import snakeCase from 'lodash-es/snakeCase.js' +import validator from 'validator' +import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate } from '@peertube/peertube-core-utils' +import { VideoResolution } from '@peertube/peertube-models' +import { objectConverter, parseBytes, parseDurationToMs } from '@peertube/peertube-server/server/helpers/core-utils.js' + +describe('Parse Bytes', function () { + + it('Should pass on valid value', async function () { + // just return it + expect(parseBytes(-1024)).to.equal(-1024) + expect(parseBytes(1024)).to.equal(1024) + expect(parseBytes(1048576)).to.equal(1048576) + expect(parseBytes('1024')).to.equal(1024) + expect(parseBytes('1048576')).to.equal(1048576) + + // sizes + expect(parseBytes('1B')).to.equal(1024) + expect(parseBytes('1MB')).to.equal(1048576) + expect(parseBytes('1GB')).to.equal(1073741824) + expect(parseBytes('1TB')).to.equal(1099511627776) + + expect(parseBytes('5GB')).to.equal(5368709120) + expect(parseBytes('5TB')).to.equal(5497558138880) + + expect(parseBytes('1024B')).to.equal(1048576) + expect(parseBytes('1024MB')).to.equal(1073741824) + expect(parseBytes('1024GB')).to.equal(1099511627776) + expect(parseBytes('1024TB')).to.equal(1125899906842624) + + // with whitespace + expect(parseBytes('1 GB')).to.equal(1073741824) + expect(parseBytes('1\tGB')).to.equal(1073741824) + + // sum value + expect(parseBytes('1TB 1024MB')).to.equal(1100585369600) + expect(parseBytes('4GB 1024MB')).to.equal(5368709120) + expect(parseBytes('4TB 1024GB')).to.equal(5497558138880) + expect(parseBytes('4TB 1024GB 0MB')).to.equal(5497558138880) + expect(parseBytes('1024TB 1024GB 1024MB')).to.equal(1127000492212224) + }) + + it('Should be invalid when given invalid value', async function () { + expect(parseBytes('6GB 1GB')).to.equal(6) + }) +}) + +describe('Parse duration', function () { + + it('Should pass when given valid value', async function () { + expect(parseDurationToMs(35)).to.equal(35) + expect(parseDurationToMs(-35)).to.equal(-35) + expect(parseDurationToMs('35 seconds')).to.equal(35 * 1000) + expect(parseDurationToMs('1 minute')).to.equal(60 * 1000) + expect(parseDurationToMs('1 hour')).to.equal(3600 * 1000) + expect(parseDurationToMs('35 hours')).to.equal(3600 * 35 * 1000) + }) + + it('Should be invalid when given invalid value', async function () { + expect(parseBytes('35m 5s')).to.equal(35) + }) +}) + +describe('Object', function () { + + it('Should convert an object', async function () { + function keyConverter (k: string) { + return snakeCase(k) + } + + function valueConverter (v: any) { + if (validator.default.isNumeric(v + '')) return parseInt('' + v, 10) + + return v + } + + const obj = { + mySuperKey: 'hello', + mySuper2Key: '45', + mySuper3Key: { + mySuperSubKey: '15', + mySuperSub2Key: 'hello', + mySuperSub3Key: [ '1', 'hello', 2 ], + mySuperSub4Key: 4 + }, + mySuper4Key: 45, + toto: { + super_key: '15', + superKey2: 'hello' + }, + super_key: { + superKey4: 15 + } + } + + const res = objectConverter(obj, keyConverter, valueConverter) + + expect(res.my_super_key).to.equal('hello') + expect(res.my_super_2_key).to.equal(45) + expect(res.my_super_3_key.my_super_sub_key).to.equal(15) + expect(res.my_super_3_key.my_super_sub_2_key).to.equal('hello') + expect(res.my_super_3_key.my_super_sub_3_key).to.deep.equal([ 1, 'hello', 2 ]) + expect(res.my_super_3_key.my_super_sub_4_key).to.equal(4) + expect(res.toto.super_key).to.equal(15) + expect(res.toto.super_key_2).to.equal('hello') + expect(res.super_key.super_key_4).to.equal(15) + + // Immutable + expect(res.mySuperKey).to.be.undefined + expect(obj['my_super_key']).to.be.undefined + }) +}) + +describe('Bitrate', function () { + + it('Should get appropriate max bitrate', function () { + const tests = [ + { resolution: VideoResolution.H_144P, ratio: 16 / 9, fps: 24, min: 200, max: 400 }, + { resolution: VideoResolution.H_240P, ratio: 16 / 9, fps: 24, min: 600, max: 800 }, + { resolution: VideoResolution.H_360P, ratio: 16 / 9, fps: 24, min: 1200, max: 1600 }, + { resolution: VideoResolution.H_480P, ratio: 16 / 9, fps: 24, min: 2000, max: 2300 }, + { resolution: VideoResolution.H_720P, ratio: 16 / 9, fps: 24, min: 4000, max: 4400 }, + { resolution: VideoResolution.H_1080P, ratio: 16 / 9, fps: 24, min: 8000, max: 10000 }, + { resolution: VideoResolution.H_4K, ratio: 16 / 9, fps: 24, min: 25000, max: 30000 } + ] + + for (const test of tests) { + expect(getMaxTheoreticalBitrate(test)).to.be.above(test.min * 1000).and.below(test.max * 1000) + } + }) + + it('Should get appropriate average bitrate', function () { + const tests = [ + { resolution: VideoResolution.H_144P, ratio: 16 / 9, fps: 24, min: 50, max: 300 }, + { resolution: VideoResolution.H_240P, ratio: 16 / 9, fps: 24, min: 350, max: 450 }, + { resolution: VideoResolution.H_360P, ratio: 16 / 9, fps: 24, min: 700, max: 900 }, + { resolution: VideoResolution.H_480P, ratio: 16 / 9, fps: 24, min: 1100, max: 1300 }, + { resolution: VideoResolution.H_720P, ratio: 16 / 9, fps: 24, min: 2300, max: 2500 }, + { resolution: VideoResolution.H_1080P, ratio: 16 / 9, fps: 24, min: 4700, max: 5000 }, + { resolution: VideoResolution.H_4K, ratio: 16 / 9, fps: 24, min: 15000, max: 17000 } + ] + + for (const test of tests) { + expect(getAverageTheoreticalBitrate(test)).to.be.above(test.min * 1000).and.below(test.max * 1000) + } + }) +}) diff --git a/packages/tests/src/server-helpers/crypto.ts b/packages/tests/src/server-helpers/crypto.ts new file mode 100644 index 000000000..4bf5b8a45 --- /dev/null +++ b/packages/tests/src/server-helpers/crypto.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { decrypt, encrypt } from '@peertube/peertube-server/server/helpers/peertube-crypto.js' + +describe('Encrypt/Descrypt', function () { + + it('Should encrypt and decrypt the string', async function () { + const secret = 'my_secret' + const str = 'my super string' + + const encrypted = await encrypt(str, secret) + const decrypted = await decrypt(encrypted, secret) + + expect(str).to.equal(decrypted) + }) + + it('Should not decrypt without the same secret', async function () { + const str = 'my super string' + + const encrypted = await encrypt(str, 'my_secret') + + let error = false + + try { + await decrypt(encrypted, 'my_sicret') + } catch (err) { + error = true + } + + expect(error).to.be.true + }) +}) diff --git a/packages/tests/src/server-helpers/dns.ts b/packages/tests/src/server-helpers/dns.ts new file mode 100644 index 000000000..64e3112a2 --- /dev/null +++ b/packages/tests/src/server-helpers/dns.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { isResolvingToUnicastOnly } from '@peertube/peertube-server/server/helpers/dns.js' + +describe('DNS helpers', function () { + + it('Should correctly check unicast IPs', async function () { + expect(await isResolvingToUnicastOnly('cpy.re')).to.be.true + expect(await isResolvingToUnicastOnly('framasoft.org')).to.be.true + expect(await isResolvingToUnicastOnly('8.8.8.8')).to.be.true + + expect(await isResolvingToUnicastOnly('127.0.0.1')).to.be.false + expect(await isResolvingToUnicastOnly('127.0.0.1.cpy.re')).to.be.false + }) +}) diff --git a/packages/tests/src/server-helpers/image.ts b/packages/tests/src/server-helpers/image.ts new file mode 100644 index 000000000..34675d385 --- /dev/null +++ b/packages/tests/src/server-helpers/image.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { remove } from 'fs-extra/esm' +import { readFile } from 'fs/promises' +import { join } from 'path' +import { buildAbsoluteFixturePath, root } from '@peertube/peertube-node-utils' +import { execPromise } from '@peertube/peertube-server/server/helpers/core-utils.js' +import { processImage } from '@peertube/peertube-server/server/helpers/image-utils.js' + +async function checkBuffers (path1: string, path2: string, equals: boolean) { + const [ buf1, buf2 ] = await Promise.all([ + readFile(path1), + readFile(path2) + ]) + + if (equals) { + expect(buf1.equals(buf2)).to.be.true + } else { + expect(buf1.equals(buf2)).to.be.false + } +} + +async function hasTitleExif (path: string) { + const result = JSON.parse(await execPromise(`exiftool -json ${path}`)) + + return result[0]?.Title === 'should be removed' +} + +describe('Image helpers', function () { + const imageDestDir = join(root(), 'test-images') + + const imageDestJPG = join(imageDestDir, 'test.jpg') + const imageDestPNG = join(imageDestDir, 'test.png') + + const thumbnailSize = { width: 280, height: 157 } + + it('Should skip processing if the source image is okay', async function () { + const input = buildAbsoluteFixturePath('custom-thumbnail.jpg') + await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + + await checkBuffers(input, imageDestJPG, true) + }) + + it('Should not skip processing if the source image does not have the appropriate extension', async function () { + const input = buildAbsoluteFixturePath('custom-thumbnail.png') + await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + + await checkBuffers(input, imageDestJPG, false) + }) + + it('Should not skip processing if the source image does not have the appropriate size', async function () { + const input = buildAbsoluteFixturePath('custom-preview.jpg') + await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + + await checkBuffers(input, imageDestJPG, false) + }) + + it('Should not skip processing if the source image does not have the appropriate size', async function () { + const input = buildAbsoluteFixturePath('custom-thumbnail-big.jpg') + await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + + await checkBuffers(input, imageDestJPG, false) + }) + + it('Should strip exif for a jpg file that can not be copied', async function () { + const input = buildAbsoluteFixturePath('exif.jpg') + expect(await hasTitleExif(input)).to.be.true + + await processImage({ path: input, destination: imageDestJPG, newSize: { width: 100, height: 100 }, keepOriginal: true }) + await checkBuffers(input, imageDestJPG, false) + + expect(await hasTitleExif(imageDestJPG)).to.be.false + }) + + it('Should strip exif for a jpg file that could be copied', async function () { + const input = buildAbsoluteFixturePath('exif.jpg') + expect(await hasTitleExif(input)).to.be.true + + await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) + await checkBuffers(input, imageDestJPG, false) + + expect(await hasTitleExif(imageDestJPG)).to.be.false + }) + + it('Should strip exif for png', async function () { + const input = buildAbsoluteFixturePath('exif.png') + expect(await hasTitleExif(input)).to.be.true + + await processImage({ path: input, destination: imageDestPNG, newSize: thumbnailSize, keepOriginal: true }) + expect(await hasTitleExif(imageDestPNG)).to.be.false + }) + + after(async function () { + await remove(imageDestDir) + }) +}) diff --git a/packages/tests/src/server-helpers/index.ts b/packages/tests/src/server-helpers/index.ts new file mode 100644 index 000000000..04a26560c --- /dev/null +++ b/packages/tests/src/server-helpers/index.ts @@ -0,0 +1,10 @@ +import './activitypub.js' +import './core-utils.js' +import './crypto.js' +import './dns.js' +import './image.js' +import './markdown.js' +import './mentions.js' +import './request.js' +import './validator.js' +import './version.js' diff --git a/packages/tests/src/server-helpers/markdown.ts b/packages/tests/src/server-helpers/markdown.ts new file mode 100644 index 000000000..96e3c34dc --- /dev/null +++ b/packages/tests/src/server-helpers/markdown.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { mdToOneLinePlainText } from '@peertube/peertube-server/server/helpers/markdown.js' +import { expect } from 'chai' + +describe('Markdown helpers', function () { + + describe('Plain text', function () { + + it('Should convert a list to plain text', function () { + const result = mdToOneLinePlainText(`* list 1 +* list 2 +* list 3`) + + expect(result).to.equal('list 1, list 2, list 3') + }) + + it('Should convert a list with indentation to plain text', function () { + const result = mdToOneLinePlainText(`Hello: + * list 1 + * list 2 + * list 3`) + + expect(result).to.equal('Hello: list 1, list 2, list 3') + }) + + it('Should convert HTML to plain text', function () { + const result = mdToOneLinePlainText(`**Hello** coucou`) + + expect(result).to.equal('Hello coucou') + }) + + it('Should convert tags to plain text', function () { + const result = mdToOneLinePlainText(`#déconversion\n#newage\n#histoire`) + + expect(result).to.equal('#déconversion #newage #histoire') + }) + }) +}) diff --git a/packages/tests/src/server-helpers/mentions.ts b/packages/tests/src/server-helpers/mentions.ts new file mode 100644 index 000000000..153931d60 --- /dev/null +++ b/packages/tests/src/server-helpers/mentions.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { extractMentions } from '@peertube/peertube-server/server/helpers/mentions.js' + +describe('Comment model', function () { + it('Should correctly extract mentions', async function () { + const text = '@florian @jean@localhost:9000 @flo @another@localhost:9000 @flo2@jean.com hello ' + + 'email@localhost:9000 coucou.com no? @chocobozzz @chocobozzz @end' + + const isOwned = true + + const result = extractMentions(text, isOwned).sort((a, b) => a.localeCompare(b)) + + expect(result).to.deep.equal([ 'another', 'chocobozzz', 'end', 'flo', 'florian', 'jean' ]) + }) +}) diff --git a/packages/tests/src/server-helpers/request.ts b/packages/tests/src/server-helpers/request.ts new file mode 100644 index 000000000..f4b9af52e --- /dev/null +++ b/packages/tests/src/server-helpers/request.ts @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists, remove } from 'fs-extra/esm' +import { join } from 'path' +import { wait } from '@peertube/peertube-core-utils' +import { root } from '@peertube/peertube-node-utils' +import { doRequest, doRequestAndSaveToFile } from '@peertube/peertube-server/server/helpers/requests.js' +import { Mock429 } from '@tests/shared/mock-servers/mock-429.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' + +describe('Request helpers', function () { + const destPath1 = join(root(), 'test-output-1.txt') + const destPath2 = join(root(), 'test-output-2.txt') + + it('Should throw an error when the bytes limit is exceeded for request', async function () { + try { + await doRequest(FIXTURE_URLS.file4K, { bodyKBLimit: 3 }) + } catch { + return + } + + throw new Error('No error thrown by do request') + }) + + it('Should throw an error when the bytes limit is exceeded for request and save file', async function () { + try { + await doRequestAndSaveToFile(FIXTURE_URLS.file4K, destPath1, { bodyKBLimit: 3 }) + } catch { + + await wait(500) + expect(await pathExists(destPath1)).to.be.false + return + } + + throw new Error('No error thrown by do request and save to file') + }) + + it('Should correctly retry on 429 error', async function () { + this.timeout(25000) + + const mock = new Mock429() + const port = await mock.initialize() + + const before = new Date().getTime() + await doRequest('http://127.0.0.1:' + port) + + expect(new Date().getTime() - before).to.be.greaterThan(2000) + + await mock.terminate() + }) + + it('Should succeed if the file is below the limit', async function () { + await doRequest(FIXTURE_URLS.file4K, { bodyKBLimit: 5 }) + await doRequestAndSaveToFile(FIXTURE_URLS.file4K, destPath2, { bodyKBLimit: 5 }) + + expect(await pathExists(destPath2)).to.be.true + }) + + after(async function () { + await remove(destPath1) + await remove(destPath2) + }) +}) diff --git a/packages/tests/src/server-helpers/validator.ts b/packages/tests/src/server-helpers/validator.ts new file mode 100644 index 000000000..792bd501c --- /dev/null +++ b/packages/tests/src/server-helpers/validator.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + isPluginStableOrUnstableVersionValid, + isPluginStableVersionValid +} from '@peertube/peertube-server/server/helpers/custom-validators/plugins.js' + +describe('Validators', function () { + + it('Should correctly check stable plugin versions', async function () { + expect(isPluginStableVersionValid('3.4.0')).to.be.true + expect(isPluginStableVersionValid('0.4.0')).to.be.true + expect(isPluginStableVersionValid('0.1.0')).to.be.true + + expect(isPluginStableVersionValid('0.1.0-beta-1')).to.be.false + expect(isPluginStableVersionValid('hello')).to.be.false + expect(isPluginStableVersionValid('0.x.a')).to.be.false + }) + + it('Should correctly check unstable plugin versions', async function () { + expect(isPluginStableOrUnstableVersionValid('3.4.0')).to.be.true + expect(isPluginStableOrUnstableVersionValid('0.4.0')).to.be.true + expect(isPluginStableOrUnstableVersionValid('0.1.0')).to.be.true + + expect(isPluginStableOrUnstableVersionValid('0.1.0-beta.1')).to.be.true + expect(isPluginStableOrUnstableVersionValid('0.1.0-alpha.45')).to.be.true + expect(isPluginStableOrUnstableVersionValid('0.1.0-rc.45')).to.be.true + + expect(isPluginStableOrUnstableVersionValid('hello')).to.be.false + expect(isPluginStableOrUnstableVersionValid('0.x.a')).to.be.false + expect(isPluginStableOrUnstableVersionValid('0.1.0-rc-45')).to.be.false + expect(isPluginStableOrUnstableVersionValid('0.1.0-rc.45d')).to.be.false + }) +}) diff --git a/packages/tests/src/server-helpers/version.ts b/packages/tests/src/server-helpers/version.ts new file mode 100644 index 000000000..76892d1e7 --- /dev/null +++ b/packages/tests/src/server-helpers/version.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { compareSemVer } from '@peertube/peertube-core-utils' + +describe('Version', function () { + + it('Should correctly compare two stable versions', async function () { + expect(compareSemVer('3.4.0', '3.5.0')).to.be.below(0) + expect(compareSemVer('3.5.0', '3.4.0')).to.be.above(0) + + expect(compareSemVer('3.4.0', '4.1.0')).to.be.below(0) + expect(compareSemVer('4.1.0', '3.4.0')).to.be.above(0) + + expect(compareSemVer('3.4.0', '3.4.1')).to.be.below(0) + expect(compareSemVer('3.4.1', '3.4.0')).to.be.above(0) + }) + + it('Should correctly compare two unstable version', async function () { + expect(compareSemVer('3.4.0-alpha', '3.4.0-beta.1')).to.be.below(0) + expect(compareSemVer('3.4.0-alpha.1', '3.4.0-beta.1')).to.be.below(0) + expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0) + expect(compareSemVer('3.4.0-beta.1', '3.5.0-alpha.1')).to.be.below(0) + + expect(compareSemVer('3.4.0-alpha.1', '3.4.0-nightly.4')).to.be.below(0) + expect(compareSemVer('3.4.0-nightly.3', '3.4.0-nightly.4')).to.be.below(0) + expect(compareSemVer('3.3.0-nightly.5', '3.4.0-nightly.4')).to.be.below(0) + }) + + it('Should correctly compare a stable and unstable versions', async function () { + expect(compareSemVer('3.4.0', '3.4.1-beta.1')).to.be.below(0) + expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0) + expect(compareSemVer('3.4.0-beta.1', '3.4.0')).to.be.below(0) + expect(compareSemVer('3.4.0-nightly.4', '3.4.0')).to.be.below(0) + }) +}) diff --git a/packages/tests/src/server-lib/index.ts b/packages/tests/src/server-lib/index.ts new file mode 100644 index 000000000..873f53e15 --- /dev/null +++ b/packages/tests/src/server-lib/index.ts @@ -0,0 +1 @@ +export * from './video-constant-registry-factory.js' diff --git a/packages/tests/src/server-lib/video-constant-registry-factory.ts b/packages/tests/src/server-lib/video-constant-registry-factory.ts new file mode 100644 index 000000000..6bf2d1db6 --- /dev/null +++ b/packages/tests/src/server-lib/video-constant-registry-factory.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { expect } from 'chai' +import { VideoPlaylistPrivacyType, VideoPrivacyType } from '@peertube/peertube-models' +import { + VIDEO_CATEGORIES, + VIDEO_LANGUAGES, + VIDEO_LICENCES, + VIDEO_PLAYLIST_PRIVACIES, + VIDEO_PRIVACIES +} from '@peertube/peertube-server/server/initializers/constants.js' +import { VideoConstantManagerFactory } from '@peertube/peertube-server/server/lib/plugins/video-constant-manager-factory.js' + +describe('VideoConstantManagerFactory', function () { + const factory = new VideoConstantManagerFactory('peertube-plugin-constants') + + afterEach(() => { + factory.resetVideoConstants('peertube-plugin-constants') + }) + + describe('VideoCategoryManager', () => { + const videoCategoryManager = factory.createVideoConstantManager('category') + + it('Should be able to list all video category constants', () => { + const constants = videoCategoryManager.getConstants() + expect(constants).to.deep.equal(VIDEO_CATEGORIES) + }) + + it('Should be able to delete a video category constant', () => { + const successfullyDeleted = videoCategoryManager.deleteConstant(1) + expect(successfullyDeleted).to.be.true + expect(videoCategoryManager.getConstantValue(1)).to.be.undefined + }) + + it('Should be able to add a video category constant', () => { + const successfullyAdded = videoCategoryManager.addConstant(42, 'The meaning of life') + expect(successfullyAdded).to.be.true + expect(videoCategoryManager.getConstantValue(42)).to.equal('The meaning of life') + }) + + it('Should be able to reset video category constants', () => { + videoCategoryManager.deleteConstant(1) + videoCategoryManager.resetConstants() + expect(videoCategoryManager.getConstantValue(1)).not.be.undefined + }) + }) + + describe('VideoLicenceManager', () => { + const videoLicenceManager = factory.createVideoConstantManager('licence') + it('Should be able to list all video licence constants', () => { + const constants = videoLicenceManager.getConstants() + expect(constants).to.deep.equal(VIDEO_LICENCES) + }) + + it('Should be able to delete a video licence constant', () => { + const successfullyDeleted = videoLicenceManager.deleteConstant(1) + expect(successfullyDeleted).to.be.true + expect(videoLicenceManager.getConstantValue(1)).to.be.undefined + }) + + it('Should be able to add a video licence constant', () => { + const successfullyAdded = videoLicenceManager.addConstant(42, 'European Union Public Licence') + expect(successfullyAdded).to.be.true + expect(videoLicenceManager.getConstantValue(42 as any)).to.equal('European Union Public Licence') + }) + + it('Should be able to reset video licence constants', () => { + videoLicenceManager.deleteConstant(1) + videoLicenceManager.resetConstants() + expect(videoLicenceManager.getConstantValue(1)).not.be.undefined + }) + }) + + describe('PlaylistPrivacyManager', () => { + const playlistPrivacyManager = factory.createVideoConstantManager('playlistPrivacy') + it('Should be able to list all video playlist privacy constants', () => { + const constants = playlistPrivacyManager.getConstants() + expect(constants).to.deep.equal(VIDEO_PLAYLIST_PRIVACIES) + }) + + it('Should be able to delete a video playlist privacy constant', () => { + const successfullyDeleted = playlistPrivacyManager.deleteConstant(1) + expect(successfullyDeleted).to.be.true + expect(playlistPrivacyManager.getConstantValue(1)).to.be.undefined + }) + + it('Should be able to add a video playlist privacy constant', () => { + const successfullyAdded = playlistPrivacyManager.addConstant(42 as any, 'Friends only') + expect(successfullyAdded).to.be.true + expect(playlistPrivacyManager.getConstantValue(42 as any)).to.equal('Friends only') + }) + + it('Should be able to reset video playlist privacy constants', () => { + playlistPrivacyManager.deleteConstant(1) + playlistPrivacyManager.resetConstants() + expect(playlistPrivacyManager.getConstantValue(1)).not.be.undefined + }) + }) + + describe('VideoPrivacyManager', () => { + const videoPrivacyManager = factory.createVideoConstantManager('privacy') + it('Should be able to list all video privacy constants', () => { + const constants = videoPrivacyManager.getConstants() + expect(constants).to.deep.equal(VIDEO_PRIVACIES) + }) + + it('Should be able to delete a video privacy constant', () => { + const successfullyDeleted = videoPrivacyManager.deleteConstant(1) + expect(successfullyDeleted).to.be.true + expect(videoPrivacyManager.getConstantValue(1)).to.be.undefined + }) + + it('Should be able to add a video privacy constant', () => { + const successfullyAdded = videoPrivacyManager.addConstant(42 as any, 'Friends only') + expect(successfullyAdded).to.be.true + expect(videoPrivacyManager.getConstantValue(42 as any)).to.equal('Friends only') + }) + + it('Should be able to reset video privacy constants', () => { + videoPrivacyManager.deleteConstant(1) + videoPrivacyManager.resetConstants() + expect(videoPrivacyManager.getConstantValue(1)).not.be.undefined + }) + }) + + describe('VideoLanguageManager', () => { + const videoLanguageManager = factory.createVideoConstantManager('language') + it('Should be able to list all video language constants', () => { + const constants = videoLanguageManager.getConstants() + expect(constants).to.deep.equal(VIDEO_LANGUAGES) + }) + + it('Should be able to add a video language constant', () => { + const successfullyAdded = videoLanguageManager.addConstant('fr', 'Fr occitan') + expect(successfullyAdded).to.be.true + expect(videoLanguageManager.getConstantValue('fr')).to.equal('Fr occitan') + }) + + it('Should be able to delete a video language constant', () => { + videoLanguageManager.addConstant('fr', 'Fr occitan') + const successfullyDeleted = videoLanguageManager.deleteConstant('fr') + expect(successfullyDeleted).to.be.true + expect(videoLanguageManager.getConstantValue('fr')).to.be.undefined + }) + + it('Should be able to reset video language constants', () => { + videoLanguageManager.addConstant('fr', 'Fr occitan') + videoLanguageManager.resetConstants() + expect(videoLanguageManager.getConstantValue('fr')).to.be.undefined + }) + }) +}) diff --git a/packages/tests/src/shared/actors.ts b/packages/tests/src/shared/actors.ts new file mode 100644 index 000000000..02d507a49 --- /dev/null +++ b/packages/tests/src/shared/actors.ts @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists } from 'fs-extra/esm' +import { readdir } from 'fs/promises' +import { Account, VideoChannel } from '@peertube/peertube-models' +import { PeerTubeServer } from '@peertube/peertube-server-commands' + +async function expectChannelsFollows (options: { + server: PeerTubeServer + handle: string + followers: number + following: number +}) { + const { server } = options + const { data } = await server.channels.list() + + return expectActorFollow({ ...options, data }) +} + +async function expectAccountFollows (options: { + server: PeerTubeServer + handle: string + followers: number + following: number +}) { + const { server } = options + const { data } = await server.accounts.list() + + return expectActorFollow({ ...options, data }) +} + +async function checkActorFilesWereRemoved (filename: string, server: PeerTubeServer) { + for (const directory of [ 'avatars' ]) { + const directoryPath = server.getDirectoryPath(directory) + + const directoryExists = await pathExists(directoryPath) + expect(directoryExists).to.be.true + + const files = await readdir(directoryPath) + for (const file of files) { + expect(file).to.not.contain(filename) + } + } +} + +export { + expectAccountFollows, + expectChannelsFollows, + checkActorFilesWereRemoved +} + +// --------------------------------------------------------------------------- + +function expectActorFollow (options: { + server: PeerTubeServer + data: (Account | VideoChannel)[] + handle: string + followers: number + following: number +}) { + const { server, data, handle, followers, following } = options + + const actor = data.find(a => a.name + '@' + a.host === handle) + const message = `${handle} on ${server.url}` + + expect(actor, message).to.exist + expect(actor.followersCount).to.equal(followers, message) + expect(actor.followingCount).to.equal(following, message) +} diff --git a/packages/tests/src/shared/captions.ts b/packages/tests/src/shared/captions.ts new file mode 100644 index 000000000..436cf8dcc --- /dev/null +++ b/packages/tests/src/shared/captions.ts @@ -0,0 +1,21 @@ +import { expect } from 'chai' +import request from 'supertest' +import { HttpStatusCode } from '@peertube/peertube-models' + +async function testCaptionFile (url: string, captionPath: string, toTest: RegExp | string) { + const res = await request(url) + .get(captionPath) + .expect(HttpStatusCode.OK_200) + + if (toTest instanceof RegExp) { + expect(res.text).to.match(toTest) + } else { + expect(res.text).to.contain(toTest) + } +} + +// --------------------------------------------------------------------------- + +export { + testCaptionFile +} diff --git a/packages/tests/src/shared/checks.ts b/packages/tests/src/shared/checks.ts new file mode 100644 index 000000000..fea618a30 --- /dev/null +++ b/packages/tests/src/shared/checks.ts @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { expect } from 'chai' +import { pathExists } from 'fs-extra/esm' +import { readFile } from 'fs/promises' +import { join } from 'path' +import { HttpStatusCode } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { makeGetRequest, PeerTubeServer } from '@peertube/peertube-server-commands' + +// Default interval -> 5 minutes +function dateIsValid (dateString: string | Date, interval = 300000) { + const dateToCheck = new Date(dateString) + const now = new Date() + + return Math.abs(now.getTime() - dateToCheck.getTime()) <= interval +} + +function expectStartWith (str: string, start: string) { + expect(str.startsWith(start), `${str} does not start with ${start}`).to.be.true +} + +function expectNotStartWith (str: string, start: string) { + expect(str.startsWith(start), `${str} does not start with ${start}`).to.be.false +} + +function expectEndWith (str: string, end: string) { + expect(str.endsWith(end), `${str} does not end with ${end}`).to.be.true +} + +// --------------------------------------------------------------------------- + +async function expectLogDoesNotContain (server: PeerTubeServer, str: string) { + const content = await server.servers.getLogContent() + + expect(content.toString()).to.not.contain(str) +} + +async function expectLogContain (server: PeerTubeServer, str: string) { + const content = await server.servers.getLogContent() + + expect(content.toString()).to.contain(str) +} + +async function testImageSize (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') { + const res = await makeGetRequest({ + url, + path: imageHTTPPath, + expectedStatus: HttpStatusCode.OK_200 + }) + + const body = res.body + + const data = await readFile(buildAbsoluteFixturePath(imageName + extension)) + const minLength = data.length - ((40 * data.length) / 100) + const maxLength = data.length + ((40 * data.length) / 100) + + expect(body.length).to.be.above(minLength, 'the generated image is way smaller than the recorded fixture') + expect(body.length).to.be.below(maxLength, 'the generated image is way larger than the recorded fixture') +} + +async function testImageGeneratedByFFmpeg (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') { + if (process.env.ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS !== 'true') { + console.log( + 'Pixel comparison of image generated by ffmpeg is disabled. ' + + 'You can enable it using `ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS=true env variable') + return + } + + return testImage(url, imageName, imageHTTPPath, extension) +} + +async function testImage (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') { + const res = await makeGetRequest({ + url, + path: imageHTTPPath, + expectedStatus: HttpStatusCode.OK_200 + }) + + const body = res.body + const data = await readFile(buildAbsoluteFixturePath(imageName + extension)) + + const { PNG } = await import('pngjs') + const JPEG = await import('jpeg-js') + const pixelmatch = (await import('pixelmatch')).default + + const img1 = imageHTTPPath.endsWith('.png') + ? PNG.sync.read(body) + : JPEG.decode(body) + + const img2 = extension === '.png' + ? PNG.sync.read(data) + : JPEG.decode(data) + + const result = pixelmatch(img1.data, img2.data, null, img1.width, img1.height, { threshold: 0.1 }) + + expect(result).to.equal(0, `${imageHTTPPath} image is not the same as ${imageName}${extension}`) +} + +async function testFileExistsOrNot (server: PeerTubeServer, directory: string, filePath: string, exist: boolean) { + const base = server.servers.buildDirectory(directory) + + expect(await pathExists(join(base, filePath))).to.equal(exist) +} + +// --------------------------------------------------------------------------- + +function checkBadStartPagination (url: string, path: string, token?: string, query = {}) { + return makeGetRequest({ + url, + path, + token, + query: { ...query, start: 'hello' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) +} + +async function checkBadCountPagination (url: string, path: string, token?: string, query = {}) { + await makeGetRequest({ + url, + path, + token, + query: { ...query, count: 'hello' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeGetRequest({ + url, + path, + token, + query: { ...query, count: 2000 }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) +} + +function checkBadSortPagination (url: string, path: string, token?: string, query = {}) { + return makeGetRequest({ + url, + path, + token, + query: { ...query, sort: 'hello' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) +} + +// --------------------------------------------------------------------------- + +async function checkVideoDuration (server: PeerTubeServer, videoUUID: string, duration: number) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.duration).to.be.approximately(duration, 1) + + for (const file of video.files) { + const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl }) + + for (const stream of metadata.streams) { + expect(Math.round(stream.duration)).to.be.approximately(duration, 1) + } + } +} + +export { + dateIsValid, + testImageGeneratedByFFmpeg, + testImageSize, + testImage, + expectLogDoesNotContain, + testFileExistsOrNot, + expectStartWith, + expectNotStartWith, + expectEndWith, + checkBadStartPagination, + checkBadCountPagination, + checkBadSortPagination, + checkVideoDuration, + expectLogContain +} diff --git a/packages/tests/src/shared/directories.ts b/packages/tests/src/shared/directories.ts new file mode 100644 index 000000000..f21e7b7c6 --- /dev/null +++ b/packages/tests/src/shared/directories.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists } from 'fs-extra/esm' +import { readdir } from 'fs/promises' +import { homedir } from 'os' +import { join } from 'path' +import { PeerTubeServer } from '@peertube/peertube-server-commands' +import { PeerTubeRunnerProcess } from './peertube-runner-process.js' + +export async function checkTmpIsEmpty (server: PeerTubeServer) { + await checkDirectoryIsEmpty(server, 'tmp', [ 'plugins-global.css', 'hls', 'resumable-uploads' ]) + + if (await pathExists(server.getDirectoryPath('tmp/hls'))) { + await checkDirectoryIsEmpty(server, 'tmp/hls') + } +} + +export async function checkPersistentTmpIsEmpty (server: PeerTubeServer) { + await checkDirectoryIsEmpty(server, 'tmp-persistent') +} + +export async function checkDirectoryIsEmpty (server: PeerTubeServer, directory: string, exceptions: string[] = []) { + const directoryPath = server.getDirectoryPath(directory) + + const directoryExists = await pathExists(directoryPath) + expect(directoryExists).to.be.true + + const files = await readdir(directoryPath) + const filtered = files.filter(f => exceptions.includes(f) === false) + + expect(filtered).to.have.lengthOf(0) +} + +export async function checkPeerTubeRunnerCacheIsEmpty (runner: PeerTubeRunnerProcess) { + const directoryPath = join(homedir(), '.cache', 'peertube-runner-nodejs', runner.getId(), 'transcoding') + + const directoryExists = await pathExists(directoryPath) + expect(directoryExists).to.be.true + + const files = await readdir(directoryPath) + + expect(files, 'Directory content: ' + files.join(', ')).to.have.lengthOf(0) +} diff --git a/packages/tests/src/shared/generate.ts b/packages/tests/src/shared/generate.ts new file mode 100644 index 000000000..ab2ecaf40 --- /dev/null +++ b/packages/tests/src/shared/generate.ts @@ -0,0 +1,79 @@ +import { expect } from 'chai' +import { ensureDir, pathExists } from 'fs-extra/esm' +import { dirname } from 'path' +import { getMaxTheoreticalBitrate } from '@peertube/peertube-core-utils' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { getVideoStreamBitrate, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@peertube/peertube-ffmpeg' + +async function ensureHasTooBigBitrate (fixturePath: string) { + const bitrate = await getVideoStreamBitrate(fixturePath) + const dataResolution = await getVideoStreamDimensionsInfo(fixturePath) + const fps = await getVideoStreamFPS(fixturePath) + + const maxBitrate = getMaxTheoreticalBitrate({ ...dataResolution, fps }) + expect(bitrate).to.be.above(maxBitrate) +} + +async function generateHighBitrateVideo () { + const tempFixturePath = buildAbsoluteFixturePath('video_high_bitrate_1080p.mp4', true) + + await ensureDir(dirname(tempFixturePath)) + + const exists = await pathExists(tempFixturePath) + + if (!exists) { + const ffmpeg = (await import('fluent-ffmpeg')).default + + console.log('Generating high bitrate video.') + + // Generate a random, high bitrate video on the fly, so we don't have to include + // a large file in the repo. The video needs to have a certain minimum length so + // that FFmpeg properly applies bitrate limits. + // https://stackoverflow.com/a/15795112 + return new Promise((res, rej) => { + ffmpeg() + .outputOptions([ '-f rawvideo', '-video_size 1920x1080', '-i /dev/urandom' ]) + .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ]) + .outputOptions([ '-maxrate 10M', '-bufsize 10M' ]) + .output(tempFixturePath) + .on('error', rej) + .on('end', () => res(tempFixturePath)) + .run() + }) + } + + await ensureHasTooBigBitrate(tempFixturePath) + + return tempFixturePath +} + +async function generateVideoWithFramerate (fps = 60) { + const tempFixturePath = buildAbsoluteFixturePath(`video_${fps}fps.mp4`, true) + + await ensureDir(dirname(tempFixturePath)) + + const exists = await pathExists(tempFixturePath) + if (!exists) { + const ffmpeg = (await import('fluent-ffmpeg')).default + + console.log('Generating video with framerate %d.', fps) + + return new Promise((res, rej) => { + ffmpeg() + .outputOptions([ '-f rawvideo', '-video_size 1280x720', '-i /dev/urandom' ]) + .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ]) + .outputOptions([ `-r ${fps}` ]) + .output(tempFixturePath) + .on('error', rej) + .on('end', () => res(tempFixturePath)) + .run() + }) + } + + return tempFixturePath +} + +export { + generateHighBitrateVideo, + generateVideoWithFramerate +} diff --git a/packages/tests/src/shared/live.ts b/packages/tests/src/shared/live.ts new file mode 100644 index 000000000..9c7991b0d --- /dev/null +++ b/packages/tests/src/shared/live.ts @@ -0,0 +1,186 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists } from 'fs-extra/esm' +import { readdir } from 'fs/promises' +import { join } from 'path' +import { sha1 } from '@peertube/peertube-node-utils' +import { LiveVideo, VideoStreamingPlaylistType } from '@peertube/peertube-models' +import { ObjectStorageCommand, PeerTubeServer } from '@peertube/peertube-server-commands' +import { SQLCommand } from './sql-command.js' +import { checkLiveSegmentHash, checkResolutionsInMasterPlaylist } from './streaming-playlists.js' + +async function checkLiveCleanup (options: { + server: PeerTubeServer + videoUUID: string + permanent: boolean + savedResolutions?: number[] +}) { + const { server, videoUUID, permanent, savedResolutions = [] } = options + + const basePath = server.servers.buildDirectory('streaming-playlists') + const hlsPath = join(basePath, 'hls', videoUUID) + + if (permanent) { + if (!await pathExists(hlsPath)) return + + const files = await readdir(hlsPath) + expect(files).to.have.lengthOf(0) + return + } + + if (savedResolutions.length === 0) { + return checkUnsavedLiveCleanup(server, videoUUID, hlsPath) + } + + return checkSavedLiveCleanup(hlsPath, savedResolutions) +} + +// --------------------------------------------------------------------------- + +async function testLiveVideoResolutions (options: { + sqlCommand: SQLCommand + originServer: PeerTubeServer + + servers: PeerTubeServer[] + liveVideoId: string + resolutions: number[] + transcoded: boolean + + objectStorage?: ObjectStorageCommand + objectStorageBaseUrl?: string +}) { + const { + originServer, + sqlCommand, + servers, + liveVideoId, + resolutions, + transcoded, + objectStorage, + objectStorageBaseUrl = objectStorage?.getMockPlaylistBaseUrl() + } = options + + for (const server of servers) { + const { data } = await server.videos.list() + expect(data.find(v => v.uuid === liveVideoId)).to.exist + + const video = await server.videos.get({ id: liveVideoId }) + expect(video.streamingPlaylists).to.have.lengthOf(1) + + const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS) + expect(hlsPlaylist).to.exist + expect(hlsPlaylist.files).to.have.lengthOf(0) // Only fragmented mp4 files are displayed + + await checkResolutionsInMasterPlaylist({ + server, + playlistUrl: hlsPlaylist.playlistUrl, + resolutions, + transcoded, + withRetry: !!objectStorage + }) + + if (objectStorage) { + expect(hlsPlaylist.playlistUrl).to.contain(objectStorageBaseUrl) + } + + for (let i = 0; i < resolutions.length; i++) { + const segmentNum = 3 + const segmentName = `${i}-00000${segmentNum}.ts` + await originServer.live.waitUntilSegmentGeneration({ + server: originServer, + videoUUID: video.uuid, + playlistNumber: i, + segment: segmentNum, + objectStorage, + objectStorageBaseUrl + }) + + const baseUrl = objectStorage + ? join(objectStorageBaseUrl, 'hls') + : originServer.url + '/static/streaming-playlists/hls' + + if (objectStorage) { + expect(hlsPlaylist.segmentsSha256Url).to.contain(objectStorageBaseUrl) + } + + const subPlaylist = await originServer.streamingPlaylists.get({ + url: `${baseUrl}/${video.uuid}/${i}.m3u8`, + withRetry: !!objectStorage // With object storage, the request may fail because of inconsistent data in S3 + }) + + expect(subPlaylist).to.contain(segmentName) + + await checkLiveSegmentHash({ + server, + baseUrlSegment: baseUrl, + videoUUID: video.uuid, + segmentName, + hlsPlaylist, + withRetry: !!objectStorage // With object storage, the request may fail because of inconsistent data in S3 + }) + + if (originServer.internalServerNumber === server.internalServerNumber) { + const infohash = sha1(`${2 + hlsPlaylist.playlistUrl}+V${i}`) + const dbInfohashes = await sqlCommand.getPlaylistInfohash(hlsPlaylist.id) + + expect(dbInfohashes).to.include(infohash) + } + } + } +} + +// --------------------------------------------------------------------------- + +export { + checkLiveCleanup, + testLiveVideoResolutions +} + +// --------------------------------------------------------------------------- + +async function checkSavedLiveCleanup (hlsPath: string, savedResolutions: number[] = []) { + const files = await readdir(hlsPath) + + // fragmented file and playlist per resolution + master playlist + segments sha256 json file + expect(files, `Directory content: ${files.join(', ')}`).to.have.lengthOf(savedResolutions.length * 2 + 2) + + for (const resolution of savedResolutions) { + const fragmentedFile = files.find(f => f.endsWith(`-${resolution}-fragmented.mp4`)) + expect(fragmentedFile).to.exist + + const playlistFile = files.find(f => f.endsWith(`${resolution}.m3u8`)) + expect(playlistFile).to.exist + } + + const masterPlaylistFile = files.find(f => f.endsWith('-master.m3u8')) + expect(masterPlaylistFile).to.exist + + const shaFile = files.find(f => f.endsWith('-segments-sha256.json')) + expect(shaFile).to.exist +} + +async function checkUnsavedLiveCleanup (server: PeerTubeServer, videoUUID: string, hlsPath: string) { + let live: LiveVideo + + try { + live = await server.live.get({ videoId: videoUUID }) + } catch {} + + if (live?.permanentLive) { + expect(await pathExists(hlsPath)).to.be.true + + const hlsFiles = await readdir(hlsPath) + expect(hlsFiles).to.have.lengthOf(1) // Only replays directory + + const replayDir = join(hlsPath, 'replay') + expect(await pathExists(replayDir)).to.be.true + + const replayFiles = await readdir(join(hlsPath, 'replay')) + expect(replayFiles).to.have.lengthOf(0) + + return + } + + expect(await pathExists(hlsPath)).to.be.false +} diff --git a/packages/tests/src/shared/mock-servers/index.ts b/packages/tests/src/shared/mock-servers/index.ts new file mode 100644 index 000000000..9d1c63c67 --- /dev/null +++ b/packages/tests/src/shared/mock-servers/index.ts @@ -0,0 +1,8 @@ +export * from './mock-429.js' +export * from './mock-email.js' +export * from './mock-http.js' +export * from './mock-instances-index.js' +export * from './mock-joinpeertube-versions.js' +export * from './mock-object-storage.js' +export * from './mock-plugin-blocklist.js' +export * from './mock-proxy.js' diff --git a/packages/tests/src/shared/mock-servers/mock-429.ts b/packages/tests/src/shared/mock-servers/mock-429.ts new file mode 100644 index 000000000..5fcb1447d --- /dev/null +++ b/packages/tests/src/shared/mock-servers/mock-429.ts @@ -0,0 +1,33 @@ +import express from 'express' +import { Server } from 'http' +import { getPort, randomListen, terminateServer } from './shared.js' + +export class Mock429 { + private server: Server + private responseSent = false + + async initialize () { + const app = express() + + app.get('/', (req: express.Request, res: express.Response, next: express.NextFunction) => { + + if (!this.responseSent) { + this.responseSent = true + + // Retry after 5 seconds + res.header('retry-after', '2') + return res.sendStatus(429) + } + + return res.sendStatus(200) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + terminate () { + return terminateServer(this.server) + } +} diff --git a/packages/tests/src/shared/mock-servers/mock-email.ts b/packages/tests/src/shared/mock-servers/mock-email.ts new file mode 100644 index 000000000..7c618e57f --- /dev/null +++ b/packages/tests/src/shared/mock-servers/mock-email.ts @@ -0,0 +1,62 @@ +import MailDev from '@peertube/maildev' +import { randomInt } from '@peertube/peertube-core-utils' +import { parallelTests } from '@peertube/peertube-node-utils' + +class MockSmtpServer { + + private static instance: MockSmtpServer + private started = false + private maildev: any + private emails: object[] + + private constructor () { } + + collectEmails (emailsCollection: object[]) { + return new Promise((res, rej) => { + const port = parallelTests() ? randomInt(1025, 2000) : 1025 + this.emails = emailsCollection + + if (this.started) { + return res(undefined) + } + + this.maildev = new MailDev({ + ip: '127.0.0.1', + smtp: port, + disableWeb: true, + silent: true + }) + + this.maildev.on('new', email => { + this.emails.push(email) + }) + + this.maildev.listen(err => { + if (err) return rej(err) + + this.started = true + + return res(port) + }) + }) + } + + kill () { + if (!this.maildev) return + + this.maildev.close() + + this.maildev = null + MockSmtpServer.instance = null + } + + static get Instance () { + return this.instance || (this.instance = new this()) + } +} + +// --------------------------------------------------------------------------- + +export { + MockSmtpServer +} diff --git a/packages/tests/src/shared/mock-servers/mock-http.ts b/packages/tests/src/shared/mock-servers/mock-http.ts new file mode 100644 index 000000000..bc1a9ce91 --- /dev/null +++ b/packages/tests/src/shared/mock-servers/mock-http.ts @@ -0,0 +1,23 @@ +import express from 'express' +import { Server } from 'http' +import { getPort, randomListen, terminateServer } from './shared.js' + +export class MockHTTP { + private server: Server + + async initialize () { + const app = express() + + app.get('/*', (req: express.Request, res: express.Response, next: express.NextFunction) => { + return res.sendStatus(200) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + terminate () { + return terminateServer(this.server) + } +} diff --git a/packages/tests/src/shared/mock-servers/mock-instances-index.ts b/packages/tests/src/shared/mock-servers/mock-instances-index.ts new file mode 100644 index 000000000..a21367358 --- /dev/null +++ b/packages/tests/src/shared/mock-servers/mock-instances-index.ts @@ -0,0 +1,46 @@ +import express from 'express' +import { Server } from 'http' +import { getPort, randomListen, terminateServer } from './shared.js' + +export class MockInstancesIndex { + private server: Server + + private readonly indexInstances: { host: string, createdAt: string }[] = [] + + async initialize () { + const app = express() + + app.use('/', (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (process.env.DEBUG) console.log('Receiving request on mocked server %s.', req.url) + + return next() + }) + + app.get('/api/v1/instances/hosts', (req: express.Request, res: express.Response) => { + const since = req.query.since + + const filtered = this.indexInstances.filter(i => { + if (!since) return true + + return i.createdAt > since + }) + + return res.json({ + total: filtered.length, + data: filtered + }) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + addInstance (host: string) { + this.indexInstances.push({ host, createdAt: new Date().toISOString() }) + } + + terminate () { + return terminateServer(this.server) + } +} diff --git a/packages/tests/src/shared/mock-servers/mock-joinpeertube-versions.ts b/packages/tests/src/shared/mock-servers/mock-joinpeertube-versions.ts new file mode 100644 index 000000000..0783165e4 --- /dev/null +++ b/packages/tests/src/shared/mock-servers/mock-joinpeertube-versions.ts @@ -0,0 +1,34 @@ +import express from 'express' +import { Server } from 'http' +import { getPort, randomListen } from './shared.js' + +export class MockJoinPeerTubeVersions { + private server: Server + private latestVersion: string + + async initialize () { + const app = express() + + app.use('/', (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (process.env.DEBUG) console.log('Receiving request on mocked server %s.', req.url) + + return next() + }) + + app.get('/versions.json', (req: express.Request, res: express.Response) => { + return res.json({ + peertube: { + latestVersion: this.latestVersion + } + }) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + setLatestVersion (latestVersion: string) { + this.latestVersion = latestVersion + } +} diff --git a/packages/tests/src/shared/mock-servers/mock-object-storage.ts b/packages/tests/src/shared/mock-servers/mock-object-storage.ts new file mode 100644 index 000000000..f97c57fd7 --- /dev/null +++ b/packages/tests/src/shared/mock-servers/mock-object-storage.ts @@ -0,0 +1,41 @@ +import express from 'express' +import got, { RequestError } from 'got' +import { Server } from 'http' +import { pipeline } from 'stream' +import { ObjectStorageCommand } from '@peertube/peertube-server-commands' +import { getPort, randomListen, terminateServer } from './shared.js' + +export class MockObjectStorageProxy { + private server: Server + + async initialize () { + const app = express() + + app.get('/:bucketName/:path(*)', (req: express.Request, res: express.Response, next: express.NextFunction) => { + const url = `http://${req.params.bucketName}.${ObjectStorageCommand.getMockEndpointHost()}/${req.params.path}` + + if (process.env.DEBUG) { + console.log('Receiving request on mocked server %s.', req.url) + console.log('Proxifying request to %s', url) + } + + return pipeline( + got.stream(url, { throwHttpErrors: false }), + res, + (err: RequestError) => { + if (!err) return + + console.error('Pipeline failed.', err) + } + ) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + terminate () { + return terminateServer(this.server) + } +} diff --git a/packages/tests/src/shared/mock-servers/mock-plugin-blocklist.ts b/packages/tests/src/shared/mock-servers/mock-plugin-blocklist.ts new file mode 100644 index 000000000..c0b6518ba --- /dev/null +++ b/packages/tests/src/shared/mock-servers/mock-plugin-blocklist.ts @@ -0,0 +1,36 @@ +import express, { Request, Response } from 'express' +import { Server } from 'http' +import { getPort, randomListen, terminateServer } from './shared.js' + +type BlocklistResponse = { + data: { + value: string + action?: 'add' | 'remove' + updatedAt?: string + }[] +} + +export class MockBlocklist { + private body: BlocklistResponse + private server: Server + + async initialize () { + const app = express() + + app.get('/blocklist', (req: Request, res: Response) => { + return res.json(this.body) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + replace (body: BlocklistResponse) { + this.body = body + } + + terminate () { + return terminateServer(this.server) + } +} diff --git a/packages/tests/src/shared/mock-servers/mock-proxy.ts b/packages/tests/src/shared/mock-servers/mock-proxy.ts new file mode 100644 index 000000000..e731670d8 --- /dev/null +++ b/packages/tests/src/shared/mock-servers/mock-proxy.ts @@ -0,0 +1,24 @@ +import { createServer, Server } from 'http' +import { createProxy } from 'proxy' +import { getPort, terminateServer } from './shared.js' + +class MockProxy { + private server: Server + + initialize () { + return new Promise(res => { + this.server = createProxy(createServer()) + this.server.listen(0, () => res(getPort(this.server))) + }) + } + + terminate () { + return terminateServer(this.server) + } +} + +// --------------------------------------------------------------------------- + +export { + MockProxy +} diff --git a/packages/tests/src/shared/mock-servers/shared.ts b/packages/tests/src/shared/mock-servers/shared.ts new file mode 100644 index 000000000..235642439 --- /dev/null +++ b/packages/tests/src/shared/mock-servers/shared.ts @@ -0,0 +1,33 @@ +import { Express } from 'express' +import { Server } from 'http' +import { AddressInfo } from 'net' + +function randomListen (app: Express) { + return new Promise(res => { + const server = app.listen(0, () => res(server)) + }) +} + +function getPort (server: Server) { + const address = server.address() as AddressInfo + + return address.port +} + +function terminateServer (server: Server) { + if (!server) return Promise.resolve() + + return new Promise((res, rej) => { + server.close(err => { + if (err) return rej(err) + + return res() + }) + }) +} + +export { + randomListen, + getPort, + terminateServer +} diff --git a/packages/tests/src/shared/notifications.ts b/packages/tests/src/shared/notifications.ts new file mode 100644 index 000000000..3accd7322 --- /dev/null +++ b/packages/tests/src/shared/notifications.ts @@ -0,0 +1,891 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { + AbuseState, + AbuseStateType, + PluginType_Type, + UserNotification, + UserNotificationSetting, + UserNotificationSettingValue, + UserNotificationType +} from '@peertube/peertube-models' +import { + ConfigCommand, + PeerTubeServer, + createMultipleServers, + doubleFollow, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' +import { expect } from 'chai' +import { inspect } from 'util' +import { MockSmtpServer } from './mock-servers/index.js' + +type CheckerBaseParams = { + server: PeerTubeServer + emails: any[] + socketNotifications: UserNotification[] + token: string + check?: { web: boolean, mail: boolean } +} + +type CheckerType = 'presence' | 'absence' + +function getAllNotificationsSettings (): UserNotificationSetting { + return { + newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newInstanceFollower: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + abuseNewMessage: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + abuseStateChange: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoStudioEditionFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + } +} + +async function checkNewVideoFromSubscription (options: CheckerBaseParams & { + videoName: string + shortUUID: string + checkType: CheckerType +}) { + const { videoName, shortUUID } = options + const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, shortUUID) + checkActor(notification.video.channel) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.type !== UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION || n.video.name !== videoName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('Your subscription') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkVideoIsPublished (options: CheckerBaseParams & { + videoName: string + shortUUID: string + checkType: CheckerType +}) { + const { videoName, shortUUID } = options + const notificationType = UserNotificationType.MY_VIDEO_PUBLISHED + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, shortUUID) + checkActor(notification.video.channel) + } else { + expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + return text.includes(shortUUID) && text.includes('Your video') + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkVideoStudioEditionIsFinished (options: CheckerBaseParams & { + videoName: string + shortUUID: string + checkType: CheckerType +}) { + const { videoName, shortUUID } = options + const notificationType = UserNotificationType.MY_VIDEO_STUDIO_EDITION_FINISHED + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, shortUUID) + checkActor(notification.video.channel) + } else { + expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + return text.includes(shortUUID) && text.includes('Edition of your video') + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkMyVideoImportIsFinished (options: CheckerBaseParams & { + videoName: string + shortUUID: string + url: string + success: boolean + checkType: CheckerType +}) { + const { videoName, shortUUID, url, success } = options + + const notificationType = success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.videoImport.targetUrl).to.equal(url) + + if (success) checkVideo(notification.videoImport.video, videoName, shortUUID) + } else { + expect(notification.videoImport).to.satisfy(i => i === undefined || i.targetUrl !== url) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + const toFind = success ? ' finished' : ' error' + + return text.includes(url) && text.includes(toFind) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +// --------------------------------------------------------------------------- + +async function checkUserRegistered (options: CheckerBaseParams & { + username: string + checkType: CheckerType +}) { + const { username } = options + const notificationType = UserNotificationType.NEW_USER_REGISTRATION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkActor(notification.account, { withAvatar: false }) + expect(notification.account.name).to.equal(username) + } else { + expect(notification).to.satisfy(n => n.type !== notificationType || n.account.name !== username) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(' registered.') && text.includes(username) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkRegistrationRequest (options: CheckerBaseParams & { + username: string + registrationReason: string + checkType: CheckerType +}) { + const { username, registrationReason } = options + const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.registration.username).to.equal(username) + } else { + expect(notification).to.satisfy(n => n.type !== notificationType || n.registration.username !== username) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(' wants to register ') && text.includes(username) && text.includes(registrationReason) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +// --------------------------------------------------------------------------- + +async function checkNewActorFollow (options: CheckerBaseParams & { + followType: 'channel' | 'account' + followerName: string + followerDisplayName: string + followingDisplayName: string + checkType: CheckerType +}) { + const { followType, followerName, followerDisplayName, followingDisplayName } = options + const notificationType = UserNotificationType.NEW_FOLLOW + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkActor(notification.actorFollow.follower) + expect(notification.actorFollow.follower.displayName).to.equal(followerDisplayName) + expect(notification.actorFollow.follower.name).to.equal(followerName) + expect(notification.actorFollow.follower.host).to.not.be.undefined + + const following = notification.actorFollow.following + expect(following.displayName).to.equal(followingDisplayName) + expect(following.type).to.equal(followType) + } else { + expect(notification).to.satisfy(n => { + return n.type !== notificationType || + (n.actorFollow.follower.name !== followerName && n.actorFollow.following !== followingDisplayName) + }) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewInstanceFollower (options: CheckerBaseParams & { + followerHost: string + checkType: CheckerType +}) { + const { followerHost } = options + const notificationType = UserNotificationType.NEW_INSTANCE_FOLLOWER + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkActor(notification.actorFollow.follower, { withAvatar: false }) + expect(notification.actorFollow.follower.name).to.equal('peertube') + expect(notification.actorFollow.follower.host).to.equal(followerHost) + + expect(notification.actorFollow.following.name).to.equal('peertube') + } else { + expect(notification).to.satisfy(n => { + return n.type !== notificationType || n.actorFollow.follower.host !== followerHost + }) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes('instance has a new follower') && text.includes(followerHost) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkAutoInstanceFollowing (options: CheckerBaseParams & { + followerHost: string + followingHost: string + checkType: CheckerType +}) { + const { followerHost, followingHost } = options + const notificationType = UserNotificationType.AUTO_INSTANCE_FOLLOWING + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + const following = notification.actorFollow.following + + checkActor(following, { withAvatar: false }) + expect(following.name).to.equal('peertube') + expect(following.host).to.equal(followingHost) + + expect(notification.actorFollow.follower.name).to.equal('peertube') + expect(notification.actorFollow.follower.host).to.equal(followerHost) + } else { + expect(notification).to.satisfy(n => { + return n.type !== notificationType || n.actorFollow.following.host !== followingHost + }) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(' automatically followed a new instance') && text.includes(followingHost) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkCommentMention (options: CheckerBaseParams & { + shortUUID: string + commentId: number + threadId: number + byAccountDisplayName: string + checkType: CheckerType +}) { + const { shortUUID, commentId, threadId, byAccountDisplayName } = options + const notificationType = UserNotificationType.COMMENT_MENTION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkComment(notification.comment, commentId, threadId) + checkActor(notification.comment.account) + expect(notification.comment.account.displayName).to.equal(byAccountDisplayName) + + checkVideo(notification.comment.video, undefined, shortUUID) + } else { + expect(notification).to.satisfy(n => n.type !== notificationType || n.comment.id !== commentId) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(' mentioned ') && text.includes(shortUUID) && text.includes(byAccountDisplayName) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +let lastEmailCount = 0 + +async function checkNewCommentOnMyVideo (options: CheckerBaseParams & { + shortUUID: string + commentId: number + threadId: number + checkType: CheckerType +}) { + const { server, shortUUID, commentId, threadId, checkType, emails } = options + const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkComment(notification.comment, commentId, threadId) + checkActor(notification.comment.account) + checkVideo(notification.comment.video, undefined, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.comment === undefined || n.comment.id !== commentId + }) + } + } + + const commentUrl = `${server.url}/w/${shortUUID};threadId=${threadId}` + + function emailNotificationFinder (email: object) { + return email['text'].indexOf(commentUrl) !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) + + if (checkType === 'presence') { + // We cannot detect email duplicates, so check we received another email + expect(emails).to.have.length.above(lastEmailCount) + lastEmailCount = emails.length + } +} + +async function checkNewVideoAbuseForModerators (options: CheckerBaseParams & { + shortUUID: string + videoName: string + checkType: CheckerType +}) { + const { shortUUID, videoName } = options + const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.be.a('number') + checkVideo(notification.abuse.video, videoName, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.abuse === undefined || n.abuse.video.shortUUID !== shortUUID + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewAbuseMessage (options: CheckerBaseParams & { + abuseId: number + message: string + toEmail: string + checkType: CheckerType +}) { + const { abuseId, message, toEmail } = options + const notificationType = UserNotificationType.ABUSE_NEW_MESSAGE + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.equal(abuseId) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.type !== notificationType || n.abuse === undefined || n.abuse.id !== abuseId + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + const to = email['to'].filter(t => t.address === toEmail) + + return text.indexOf(message) !== -1 && to.length !== 0 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkAbuseStateChange (options: CheckerBaseParams & { + abuseId: number + state: AbuseStateType + checkType: CheckerType +}) { + const { abuseId, state } = options + const notificationType = UserNotificationType.ABUSE_STATE_CHANGE + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.equal(abuseId) + expect(notification.abuse.state).to.equal(state) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.abuse === undefined || n.abuse.id !== abuseId + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + + const contains = state === AbuseState.ACCEPTED + ? ' accepted' + : ' rejected' + + return text.indexOf(contains) !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewCommentAbuseForModerators (options: CheckerBaseParams & { + shortUUID: string + videoName: string + checkType: CheckerType +}) { + const { shortUUID, videoName } = options + const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.be.a('number') + checkVideo(notification.abuse.comment.video, videoName, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.abuse === undefined || n.abuse.comment.video.shortUUID !== shortUUID + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewAccountAbuseForModerators (options: CheckerBaseParams & { + displayName: string + checkType: CheckerType +}) { + const { displayName } = options + const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.be.a('number') + expect(notification.abuse.account.displayName).to.equal(displayName) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.abuse === undefined || n.abuse.account.displayName !== displayName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(displayName) !== -1 && text.indexOf('abuse') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkVideoAutoBlacklistForModerators (options: CheckerBaseParams & { + shortUUID: string + videoName: string + checkType: CheckerType +}) { + const { shortUUID, videoName } = options + const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.videoBlacklist.video.id).to.be.a('number') + checkVideo(notification.videoBlacklist.video, videoName, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.video === undefined || n.video.shortUUID !== shortUUID + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && email['text'].indexOf('video-auto-blacklist/list') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewBlacklistOnMyVideo (options: CheckerBaseParams & { + shortUUID: string + videoName: string + blacklistType: 'blacklist' | 'unblacklist' +}) { + const { videoName, shortUUID, blacklistType } = options + const notificationType = blacklistType === 'blacklist' + ? UserNotificationType.BLACKLIST_ON_MY_VIDEO + : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO + + function notificationChecker (notification: UserNotification) { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video + + checkVideo(video, videoName, shortUUID) + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + const blacklistText = blacklistType === 'blacklist' + ? 'blacklisted' + : 'unblacklisted' + + return text.includes(shortUUID) && text.includes(blacklistText) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder, checkType: 'presence' }) +} + +async function checkNewPeerTubeVersion (options: CheckerBaseParams & { + latestVersion: string + checkType: CheckerType +}) { + const { latestVersion } = options + const notificationType = UserNotificationType.NEW_PEERTUBE_VERSION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.peertube).to.exist + expect(notification.peertube.latestVersion).to.equal(latestVersion) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.peertube === undefined || n.peertube.latestVersion !== latestVersion + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + + return text.includes(latestVersion) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewPluginVersion (options: CheckerBaseParams & { + pluginType: PluginType_Type + pluginName: string + checkType: CheckerType +}) { + const { pluginName, pluginType } = options + const notificationType = UserNotificationType.NEW_PLUGIN_VERSION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.plugin.name).to.equal(pluginName) + expect(notification.plugin.type).to.equal(pluginType) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.plugin === undefined || n.plugin.name !== pluginName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + + return text.includes(pluginName) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: any = {}) { + const userNotifications: UserNotification[] = [] + const adminNotifications: UserNotification[] = [] + const adminNotificationsServer2: UserNotification[] = [] + const emails: object[] = [] + + const port = await MockSmtpServer.Instance.collectEmails(emails) + + const overrideConfig = { + ...ConfigCommand.getEmailOverrideConfig(port), + + signup: { + limit: 20 + } + } + const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg)) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultChannelAvatar(servers) + await setDefaultAccountAvatar(servers) + + if (servers[1]) { + await servers[1].config.enableStudio() + await servers[1].config.enableLive({ allowReplay: true, transcoding: false }) + } + + if (serversCount > 1) { + await doubleFollow(servers[0], servers[1]) + } + + const user = { username: 'user_1', password: 'super password' } + await servers[0].users.create({ ...user, videoQuota: 10 * 1000 * 1000 }) + const userAccessToken = await servers[0].login.getAccessToken(user) + + await servers[0].notifications.updateMySettings({ token: userAccessToken, settings: getAllNotificationsSettings() }) + await servers[0].users.updateMyAvatar({ token: userAccessToken, fixture: 'avatar.png' }) + await servers[0].channels.updateImage({ channelName: 'user_1_channel', token: userAccessToken, fixture: 'avatar.png', type: 'avatar' }) + + await servers[0].notifications.updateMySettings({ settings: getAllNotificationsSettings() }) + + if (serversCount > 1) { + await servers[1].notifications.updateMySettings({ settings: getAllNotificationsSettings() }) + } + + { + const socket = servers[0].socketIO.getUserNotificationSocket({ token: userAccessToken }) + socket.on('new-notification', n => userNotifications.push(n)) + } + { + const socket = servers[0].socketIO.getUserNotificationSocket() + socket.on('new-notification', n => adminNotifications.push(n)) + } + + if (serversCount > 1) { + const socket = servers[1].socketIO.getUserNotificationSocket() + socket.on('new-notification', n => adminNotificationsServer2.push(n)) + } + + const { videoChannels } = await servers[0].users.getMyInfo() + const channelId = videoChannels[0].id + + return { + userNotifications, + adminNotifications, + adminNotificationsServer2, + userAccessToken, + emails, + servers, + channelId, + baseOverrideConfig: overrideConfig + } +} + +// --------------------------------------------------------------------------- + +export { + type CheckerType, + type CheckerBaseParams, + + getAllNotificationsSettings, + + checkMyVideoImportIsFinished, + checkUserRegistered, + checkAutoInstanceFollowing, + checkVideoIsPublished, + checkNewVideoFromSubscription, + checkNewActorFollow, + checkNewCommentOnMyVideo, + checkNewBlacklistOnMyVideo, + checkCommentMention, + checkNewVideoAbuseForModerators, + checkVideoAutoBlacklistForModerators, + checkNewAbuseMessage, + checkAbuseStateChange, + checkNewInstanceFollower, + prepareNotificationsTest, + checkNewCommentAbuseForModerators, + checkNewAccountAbuseForModerators, + checkNewPeerTubeVersion, + checkNewPluginVersion, + checkVideoStudioEditionIsFinished, + checkRegistrationRequest +} + +// --------------------------------------------------------------------------- + +async function checkNotification (options: CheckerBaseParams & { + notificationChecker: (notification: UserNotification, checkType: CheckerType) => void + emailNotificationFinder: (email: object) => boolean + checkType: CheckerType +}) { + const { server, token, checkType, notificationChecker, emailNotificationFinder, socketNotifications, emails } = options + + const check = options.check || { web: true, mail: true } + + if (check.web) { + const notification = await server.notifications.getLatest({ token }) + + if (notification || checkType !== 'absence') { + notificationChecker(notification, checkType) + } + + const socketNotification = socketNotifications.find(n => { + try { + notificationChecker(n, 'presence') + return true + } catch { + return false + } + }) + + if (checkType === 'presence') { + const obj = inspect(socketNotifications, { depth: 5 }) + expect(socketNotification, 'The socket notification is absent when it should be present. ' + obj).to.not.be.undefined + } else { + const obj = inspect(socketNotification, { depth: 5 }) + expect(socketNotification, 'The socket notification is present when it should not be present. ' + obj).to.be.undefined + } + } + + if (check.mail) { + // Last email + const email = emails + .slice() + .reverse() + .find(e => emailNotificationFinder(e)) + + if (checkType === 'presence') { + const texts = emails.map(e => e.text) + expect(email, 'The email is absent when is should be present. ' + inspect(texts)).to.not.be.undefined + } else { + expect(email, 'The email is present when is should not be present. ' + inspect(email)).to.be.undefined + } + } +} + +function checkVideo (video: any, videoName?: string, shortUUID?: string) { + if (videoName) { + expect(video.name).to.be.a('string') + expect(video.name).to.not.be.empty + expect(video.name).to.equal(videoName) + } + + if (shortUUID) { + expect(video.shortUUID).to.be.a('string') + expect(video.shortUUID).to.not.be.empty + expect(video.shortUUID).to.equal(shortUUID) + } + + expect(video.id).to.be.a('number') +} + +function checkActor (actor: any, options: { withAvatar?: boolean } = {}) { + const { withAvatar = true } = options + + expect(actor.displayName).to.be.a('string') + expect(actor.displayName).to.not.be.empty + expect(actor.host).to.not.be.undefined + + if (withAvatar) { + expect(actor.avatars).to.be.an('array') + expect(actor.avatars).to.have.lengthOf(2) + expect(actor.avatars[0].path).to.exist.and.not.empty + } +} + +function checkComment (comment: any, commentId: number, threadId: number) { + expect(comment.id).to.equal(commentId) + expect(comment.threadId).to.equal(threadId) +} diff --git a/packages/tests/src/shared/peertube-runner-process.ts b/packages/tests/src/shared/peertube-runner-process.ts new file mode 100644 index 000000000..3d1f299f2 --- /dev/null +++ b/packages/tests/src/shared/peertube-runner-process.ts @@ -0,0 +1,104 @@ +import { ChildProcess, fork, ForkOptions } from 'child_process' +import execa from 'execa' +import { join } from 'path' +import { root } from '@peertube/peertube-node-utils' +import { PeerTubeServer } from '@peertube/peertube-server-commands' + +export class PeerTubeRunnerProcess { + private app?: ChildProcess + + constructor (private readonly server: PeerTubeServer) { + + } + + runServer (options: { + hideLogs?: boolean // default true + } = {}) { + const { hideLogs = true } = options + + return new Promise((res, rej) => { + const args = [ 'server', '--verbose', ...this.buildIdArg() ] + + const forkOptions: ForkOptions = { + detached: false, + silent: true, + execArgv: [] // Don't inject parent node options + } + + this.app = fork(this.getRunnerPath(), args, forkOptions) + + this.app.stdout.on('data', data => { + const str = data.toString() as string + + if (!hideLogs) { + console.log(str) + } + }) + + res() + }) + } + + registerPeerTubeInstance (options: { + registrationToken: string + runnerName: string + runnerDescription?: string + }) { + const { registrationToken, runnerName, runnerDescription } = options + + const args = [ + 'register', + '--url', this.server.url, + '--registration-token', registrationToken, + '--runner-name', runnerName, + ...this.buildIdArg() + ] + + if (runnerDescription) { + args.push('--runner-description') + args.push(runnerDescription) + } + + return this.runCommand(this.getRunnerPath(), args) + } + + unregisterPeerTubeInstance (options: { + runnerName: string + }) { + const { runnerName } = options + + const args = [ 'unregister', '--url', this.server.url, '--runner-name', runnerName, ...this.buildIdArg() ] + return this.runCommand(this.getRunnerPath(), args) + } + + async listRegisteredPeerTubeInstances () { + const args = [ 'list-registered', ...this.buildIdArg() ] + const { stdout } = await this.runCommand(this.getRunnerPath(), args) + + return stdout + } + + kill () { + if (!this.app) return + + process.kill(this.app.pid) + + this.app = null + } + + getId () { + return 'test-' + this.server.internalServerNumber + } + + private getRunnerPath () { + return join(root(), 'apps', 'peertube-runner', 'dist', 'peertube-runner.js') + } + + private buildIdArg () { + return [ '--id', this.getId() ] + } + + private runCommand (path: string, args: string[]) { + return execa.node(path, args, { env: { ...process.env, NODE_OPTIONS: '' } }) + } +} diff --git a/packages/tests/src/shared/plugins.ts b/packages/tests/src/shared/plugins.ts new file mode 100644 index 000000000..c2afcbcbf --- /dev/null +++ b/packages/tests/src/shared/plugins.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { PeerTubeServer } from '@peertube/peertube-server-commands' + +async function testHelloWorldRegisteredSettings (server: PeerTubeServer) { + const body = await server.plugins.getRegisteredSettings({ npmName: 'peertube-plugin-hello-world' }) + + const registeredSettings = body.registeredSettings + expect(registeredSettings).to.have.length.at.least(1) + + const adminNameSettings = registeredSettings.find(s => s.name === 'admin-name') + expect(adminNameSettings).to.not.be.undefined +} + +export { + testHelloWorldRegisteredSettings +} diff --git a/packages/tests/src/shared/requests.ts b/packages/tests/src/shared/requests.ts new file mode 100644 index 000000000..fc70ad6ed --- /dev/null +++ b/packages/tests/src/shared/requests.ts @@ -0,0 +1,12 @@ +import { doRequest } from '@peertube/peertube-server/server/helpers/requests.js' + +export function makePOSTAPRequest (url: string, body: any, httpSignature: any, headers: any) { + const options = { + method: 'POST' as 'POST', + json: body, + httpSignature, + headers + } + + return doRequest(url, options) +} diff --git a/packages/tests/src/shared/sql-command.ts b/packages/tests/src/shared/sql-command.ts new file mode 100644 index 000000000..1c4f89351 --- /dev/null +++ b/packages/tests/src/shared/sql-command.ts @@ -0,0 +1,150 @@ +import { QueryTypes, Sequelize } from 'sequelize' +import { forceNumber } from '@peertube/peertube-core-utils' +import { PeerTubeServer } from '@peertube/peertube-server-commands' + +export class SQLCommand { + private sequelize: Sequelize + + constructor (private readonly server: PeerTubeServer) { + + } + + deleteAll (table: string) { + const seq = this.getSequelize() + + const options = { type: QueryTypes.DELETE } + + return seq.query(`DELETE FROM "${table}"`, options) + } + + async getVideoShareCount () { + const [ { total } ] = await this.selectQuery<{ total: string }>(`SELECT COUNT(*) as total FROM "videoShare"`) + if (total === null) return 0 + + return parseInt(total, 10) + } + + async getInternalFileUrl (fileId: number) { + return this.selectQuery<{ fileUrl: string }>(`SELECT "fileUrl" FROM "videoFile" WHERE id = :fileId`, { fileId }) + .then(rows => rows[0].fileUrl) + } + + setActorField (to: string, field: string, value: string) { + return this.updateQuery(`UPDATE actor SET ${this.escapeColumnName(field)} = :value WHERE url = :to`, { value, to }) + } + + setVideoField (uuid: string, field: string, value: string) { + return this.updateQuery(`UPDATE video SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid }) + } + + setPlaylistField (uuid: string, field: string, value: string) { + return this.updateQuery(`UPDATE "videoPlaylist" SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid }) + } + + async countVideoViewsOf (uuid: string) { + const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' + + `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = :uuid` + + const [ { total } ] = await this.selectQuery<{ total: number }>(query, { uuid }) + if (!total) return 0 + + return forceNumber(total) + } + + getActorImage (filename: string) { + return this.selectQuery<{ width: number, height: number }>(`SELECT * FROM "actorImage" WHERE filename = :filename`, { filename }) + .then(rows => rows[0]) + } + + // --------------------------------------------------------------------------- + + setPluginVersion (pluginName: string, newVersion: string) { + return this.setPluginField(pluginName, 'version', newVersion) + } + + setPluginLatestVersion (pluginName: string, newVersion: string) { + return this.setPluginField(pluginName, 'latestVersion', newVersion) + } + + setPluginField (pluginName: string, field: string, value: string) { + return this.updateQuery( + `UPDATE "plugin" SET ${this.escapeColumnName(field)} = :value WHERE "name" = :pluginName`, + { pluginName, value } + ) + } + + // --------------------------------------------------------------------------- + + selectQuery (query: string, replacements: { [id: string]: string | number } = {}) { + const seq = this.getSequelize() + const options = { + type: QueryTypes.SELECT as QueryTypes.SELECT, + replacements + } + + return seq.query(query, options) + } + + updateQuery (query: string, replacements: { [id: string]: string | number } = {}) { + const seq = this.getSequelize() + const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE, replacements } + + return seq.query(query, options) + } + + // --------------------------------------------------------------------------- + + async getPlaylistInfohash (playlistId: number) { + const query = 'SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = :playlistId' + + const result = await this.selectQuery<{ p2pMediaLoaderInfohashes: string }>(query, { playlistId }) + if (!result || result.length === 0) return [] + + return result[0].p2pMediaLoaderInfohashes + } + + // --------------------------------------------------------------------------- + + setActorFollowScores (newScore: number) { + return this.updateQuery(`UPDATE "actorFollow" SET "score" = :newScore`, { newScore }) + } + + setTokenField (accessToken: string, field: string, value: string) { + return this.updateQuery( + `UPDATE "oAuthToken" SET ${this.escapeColumnName(field)} = :value WHERE "accessToken" = :accessToken`, + { value, accessToken } + ) + } + + async cleanup () { + if (!this.sequelize) return + + await this.sequelize.close() + this.sequelize = undefined + } + + private getSequelize () { + if (this.sequelize) return this.sequelize + + const dbname = 'peertube_test' + this.server.internalServerNumber + const username = 'peertube' + const password = 'peertube' + const host = '127.0.0.1' + const port = 5432 + + this.sequelize = new Sequelize(dbname, username, password, { + dialect: 'postgres', + host, + port, + logging: false + }) + + return this.sequelize + } + + private escapeColumnName (columnName: string) { + return this.getSequelize().escape(columnName) + .replace(/^'/, '"') + .replace(/'$/, '"') + } +} diff --git a/packages/tests/src/shared/streaming-playlists.ts b/packages/tests/src/shared/streaming-playlists.ts new file mode 100644 index 000000000..f2f0fbe85 --- /dev/null +++ b/packages/tests/src/shared/streaming-playlists.ts @@ -0,0 +1,302 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { basename, dirname, join } from 'path' +import { removeFragmentedMP4Ext, uuidRegex } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + VideoPrivacy, + VideoResolution, + VideoStreamingPlaylist, + VideoStreamingPlaylistType +} from '@peertube/peertube-models' +import { sha256 } from '@peertube/peertube-node-utils' +import { makeRawRequest, PeerTubeServer } from '@peertube/peertube-server-commands' +import { expectStartWith } from './checks.js' +import { hlsInfohashExist } from './tracker.js' +import { checkWebTorrentWorks } from './webtorrent.js' + +async function checkSegmentHash (options: { + server: PeerTubeServer + baseUrlPlaylist: string + baseUrlSegment: string + resolution: number + hlsPlaylist: VideoStreamingPlaylist + token?: string +}) { + const { server, baseUrlPlaylist, baseUrlSegment, resolution, hlsPlaylist, token } = options + const command = server.streamingPlaylists + + const file = hlsPlaylist.files.find(f => f.resolution.id === resolution) + const videoName = basename(file.fileUrl) + + const playlist = await command.get({ url: `${baseUrlPlaylist}/${removeFragmentedMP4Ext(videoName)}.m3u8`, token }) + + const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist) + + const length = parseInt(matches[1], 10) + const offset = parseInt(matches[2], 10) + const range = `${offset}-${offset + length - 1}` + + const segmentBody = await command.getFragmentedSegment({ + url: `${baseUrlSegment}/${videoName}`, + expectedStatus: HttpStatusCode.PARTIAL_CONTENT_206, + range: `bytes=${range}`, + token + }) + + const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, token }) + expect(sha256(segmentBody)).to.equal(shaBody[videoName][range], `Invalid sha256 result for ${videoName} range ${range}`) +} + +// --------------------------------------------------------------------------- + +async function checkLiveSegmentHash (options: { + server: PeerTubeServer + baseUrlSegment: string + videoUUID: string + segmentName: string + hlsPlaylist: VideoStreamingPlaylist + withRetry?: boolean +}) { + const { server, baseUrlSegment, videoUUID, segmentName, hlsPlaylist, withRetry = false } = options + const command = server.streamingPlaylists + + const segmentBody = await command.getFragmentedSegment({ url: `${baseUrlSegment}/${videoUUID}/${segmentName}`, withRetry }) + const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url, withRetry }) + + expect(sha256(segmentBody)).to.equal(shaBody[segmentName]) +} + +// --------------------------------------------------------------------------- + +async function checkResolutionsInMasterPlaylist (options: { + server: PeerTubeServer + playlistUrl: string + resolutions: number[] + token?: string + transcoded?: boolean // default true + withRetry?: boolean // default false +}) { + const { server, playlistUrl, resolutions, token, withRetry = false, transcoded = true } = options + + const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl, token, withRetry }) + + for (const resolution of resolutions) { + const base = '#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution + + if (resolution === VideoResolution.H_NOVIDEO) { + expect(masterPlaylist).to.match(new RegExp(`${base},CODECS="mp4a.40.2"`)) + } else if (transcoded) { + expect(masterPlaylist).to.match(new RegExp(`${base},(FRAME-RATE=\\d+,)?CODECS="avc1.64001f,mp4a.40.2"`)) + } else { + expect(masterPlaylist).to.match(new RegExp(`${base}`)) + } + } + + const playlistsLength = masterPlaylist.split('\n').filter(line => line.startsWith('#EXT-X-STREAM-INF:BANDWIDTH=')) + expect(playlistsLength).to.have.lengthOf(resolutions.length) +} + +async function completeCheckHlsPlaylist (options: { + servers: PeerTubeServer[] + videoUUID: string + hlsOnly: boolean + + resolutions?: number[] + objectStorageBaseUrl?: string +}) { + const { videoUUID, hlsOnly, objectStorageBaseUrl } = options + + const resolutions = options.resolutions ?? [ 240, 360, 480, 720 ] + + for (const server of options.servers) { + const videoDetails = await server.videos.getWithToken({ id: videoUUID }) + const requiresAuth = videoDetails.privacy.id === VideoPrivacy.PRIVATE || videoDetails.privacy.id === VideoPrivacy.INTERNAL + + const privatePath = requiresAuth + ? 'private/' + : '' + const token = requiresAuth + ? server.accessToken + : undefined + + const baseUrl = `http://${videoDetails.account.host}` + + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + + const hlsPlaylist = videoDetails.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) + expect(hlsPlaylist).to.not.be.undefined + + const hlsFiles = hlsPlaylist.files + expect(hlsFiles).to.have.lengthOf(resolutions.length) + + if (hlsOnly) expect(videoDetails.files).to.have.lengthOf(0) + else expect(videoDetails.files).to.have.lengthOf(resolutions.length) + + // Check JSON files + for (const resolution of resolutions) { + const file = hlsFiles.find(f => f.resolution.id === resolution) + expect(file).to.not.be.undefined + + if (file.resolution.id === VideoResolution.H_NOVIDEO) { + expect(file.resolution.label).to.equal('Audio') + } else { + expect(file.resolution.label).to.equal(resolution + 'p') + } + + expect(file.magnetUri).to.have.lengthOf.above(2) + await checkWebTorrentWorks(file.magnetUri) + + { + const nameReg = `${uuidRegex}-${file.resolution.id}` + + expect(file.torrentUrl).to.match(new RegExp(`${server.url}/lazy-static/torrents/${nameReg}-hls.torrent`)) + + if (objectStorageBaseUrl && requiresAuth) { + // eslint-disable-next-line max-len + expect(file.fileUrl).to.match(new RegExp(`${server.url}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`)) + } else if (objectStorageBaseUrl) { + expectStartWith(file.fileUrl, objectStorageBaseUrl) + } else { + expect(file.fileUrl).to.match( + new RegExp(`${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoDetails.uuid}/${nameReg}-fragmented.mp4`) + ) + } + } + + { + await Promise.all([ + makeRawRequest({ url: file.torrentUrl, token, expectedStatus: HttpStatusCode.OK_200 }), + makeRawRequest({ url: file.torrentDownloadUrl, token, expectedStatus: HttpStatusCode.OK_200 }), + makeRawRequest({ url: file.metadataUrl, token, expectedStatus: HttpStatusCode.OK_200 }), + makeRawRequest({ url: file.fileUrl, token, expectedStatus: HttpStatusCode.OK_200 }), + + makeRawRequest({ + url: file.fileDownloadUrl, + token, + expectedStatus: objectStorageBaseUrl + ? HttpStatusCode.FOUND_302 + : HttpStatusCode.OK_200 + }) + ]) + } + } + + // Check master playlist + { + await checkResolutionsInMasterPlaylist({ server, token, playlistUrl: hlsPlaylist.playlistUrl, resolutions }) + + const masterPlaylist = await server.streamingPlaylists.get({ url: hlsPlaylist.playlistUrl, token }) + + let i = 0 + for (const resolution of resolutions) { + expect(masterPlaylist).to.contain(`${resolution}.m3u8`) + expect(masterPlaylist).to.contain(`${resolution}.m3u8`) + + const url = 'http://' + videoDetails.account.host + await hlsInfohashExist(url, hlsPlaylist.playlistUrl, i) + + i++ + } + } + + // Check resolution playlists + { + for (const resolution of resolutions) { + const file = hlsFiles.find(f => f.resolution.id === resolution) + const playlistName = removeFragmentedMP4Ext(basename(file.fileUrl)) + '.m3u8' + + let url: string + if (objectStorageBaseUrl && requiresAuth) { + url = `${baseUrl}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoUUID}/${playlistName}` + } else if (objectStorageBaseUrl) { + url = `${objectStorageBaseUrl}hls/${videoUUID}/${playlistName}` + } else { + url = `${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoUUID}/${playlistName}` + } + + const subPlaylist = await server.streamingPlaylists.get({ url, token }) + + expect(subPlaylist).to.match(new RegExp(`${uuidRegex}-${resolution}-fragmented.mp4`)) + expect(subPlaylist).to.contain(basename(file.fileUrl)) + } + } + + { + let baseUrlAndPath: string + if (objectStorageBaseUrl && requiresAuth) { + baseUrlAndPath = `${baseUrl}/object-storage-proxy/streaming-playlists/hls/${privatePath}${videoUUID}` + } else if (objectStorageBaseUrl) { + baseUrlAndPath = `${objectStorageBaseUrl}hls/${videoUUID}` + } else { + baseUrlAndPath = `${baseUrl}/static/streaming-playlists/hls/${privatePath}${videoUUID}` + } + + for (const resolution of resolutions) { + await checkSegmentHash({ + server, + token, + baseUrlPlaylist: baseUrlAndPath, + baseUrlSegment: baseUrlAndPath, + resolution, + hlsPlaylist + }) + } + } + } +} + +async function checkVideoFileTokenReinjection (options: { + server: PeerTubeServer + videoUUID: string + videoFileToken: string + resolutions: number[] + isLive: boolean +}) { + const { server, resolutions, videoFileToken, videoUUID, isLive } = options + + const video = await server.videos.getWithToken({ id: videoUUID }) + const hls = video.streamingPlaylists[0] + + const query = { videoFileToken, reinjectVideoFileToken: 'true' } + const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 }) + + for (let i = 0; i < resolutions.length; i++) { + const resolution = resolutions[i] + + const suffix = isLive + ? i + : `-${resolution}` + + expect(text).to.contain(`${suffix}.m3u8?videoFileToken=${videoFileToken}&reinjectVideoFileToken=true`) + } + + const resolutionPlaylists = extractResolutionPlaylistUrls(hls.playlistUrl, text) + expect(resolutionPlaylists).to.have.lengthOf(resolutions.length) + + for (const url of resolutionPlaylists) { + const { text } = await makeRawRequest({ url, query, expectedStatus: HttpStatusCode.OK_200 }) + + const extension = isLive + ? '.ts' + : '.mp4' + + expect(text).to.contain(`${extension}?videoFileToken=${videoFileToken}`) + expect(text).not.to.contain(`reinjectVideoFileToken=true`) + } +} + +function extractResolutionPlaylistUrls (masterPath: string, masterContent: string) { + return masterContent.match(/^([^.]+\.m3u8.*)/mg) + .map(filename => join(dirname(masterPath), filename)) +} + +export { + checkSegmentHash, + checkLiveSegmentHash, + checkResolutionsInMasterPlaylist, + completeCheckHlsPlaylist, + extractResolutionPlaylistUrls, + checkVideoFileTokenReinjection +} diff --git a/packages/tests/src/shared/tests.ts b/packages/tests/src/shared/tests.ts new file mode 100644 index 000000000..d2cb040fb --- /dev/null +++ b/packages/tests/src/shared/tests.ts @@ -0,0 +1,40 @@ +const FIXTURE_URLS = { + peertube_long: 'https://peertube2.cpy.re/videos/watch/122d093a-1ede-43bd-bd34-59d2931ffc5e', + peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd', + + youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM', + + /** + * The video is used to check format-selection correctness wrt. HDR, + * which brings its own set of oddities outside of a MediaSource. + * + * The video needs to have the following format_ids: + * (which you can check by using `youtube-dl -F`): + * - (webm vp9) + * - (mp4 avc1) + * - (webm vp9.2 HDR) + */ + youtubeHDR: 'https://www.youtube.com/watch?v=RQgnBB9z_N4', + + youtubeChannel: 'https://youtube.com/channel/UCtnlZdXv3-xQzxiqfn6cjIA', + youtubePlaylist: 'https://youtube.com/playlist?list=PLRGXHPrcPd2yc2KdswlAWOxIJ8G3vgy4h', + + // eslint-disable-next-line max-len + magnet: 'magnet:?xs=https%3A%2F%2Fpeertube2.cpy.re%2Flazy-static%2Ftorrents%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.torrent&xt=urn:btih:0f498834733e8057ed5c6f2ee2b4efd8d84a76ee&dn=super+peertube2+video&tr=https%3A%2F%2Fpeertube2.cpy.re%2Ftracker%2Fannounce&tr=wss%3A%2F%2Fpeertube2.cpy.re%3A443%2Ftracker%2Fsocket&ws=https%3A%2F%2Fpeertube2.cpy.re%2Fstatic%2Fwebseed%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.mp4', + + badVideo: 'https://download.cpy.re/peertube/bad_video.mp4', + goodVideo: 'https://download.cpy.re/peertube/good_video.mp4', + goodVideo720: 'https://download.cpy.re/peertube/good_video_720.mp4', + + file4K: 'https://download.cpy.re/peertube/4k_file.txt' +} + +function buildRequestStub (): any { + return { } +} + +export { + FIXTURE_URLS, + + buildRequestStub +} diff --git a/packages/tests/src/shared/tracker.ts b/packages/tests/src/shared/tracker.ts new file mode 100644 index 000000000..6ab430456 --- /dev/null +++ b/packages/tests/src/shared/tracker.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { sha1 } from '@peertube/peertube-node-utils' +import { makeGetRequest } from '@peertube/peertube-server-commands' + +async function hlsInfohashExist (serverUrl: string, masterPlaylistUrl: string, fileNumber: number) { + const path = '/tracker/announce' + + const infohash = sha1(`2${masterPlaylistUrl}+V${fileNumber}`) + + // From bittorrent-tracker + const infohashBinary = escape(Buffer.from(infohash, 'hex').toString('binary')).replace(/[@*/+]/g, function (char) { + return '%' + char.charCodeAt(0).toString(16).toUpperCase() + }) + + const res = await makeGetRequest({ + url: serverUrl, + path, + rawQuery: `peer_id=-WW0105-NkvYO/egUAr4&info_hash=${infohashBinary}&port=42100`, + expectedStatus: 200 + }) + + expect(res.text).to.not.contain('failure') +} + +export { + hlsInfohashExist +} diff --git a/packages/tests/src/shared/video-playlists.ts b/packages/tests/src/shared/video-playlists.ts new file mode 100644 index 000000000..81dc43ed6 --- /dev/null +++ b/packages/tests/src/shared/video-playlists.ts @@ -0,0 +1,22 @@ +import { expect } from 'chai' +import { readdir } from 'fs/promises' +import { PeerTubeServer } from '@peertube/peertube-server-commands' + +async function checkPlaylistFilesWereRemoved ( + playlistUUID: string, + server: PeerTubeServer, + directories = [ 'thumbnails' ] +) { + for (const directory of directories) { + const directoryPath = server.getDirectoryPath(directory) + + const files = await readdir(directoryPath) + for (const file of files) { + expect(file).to.not.contain(playlistUUID) + } + } +} + +export { + checkPlaylistFilesWereRemoved +} diff --git a/packages/tests/src/shared/videos.ts b/packages/tests/src/shared/videos.ts new file mode 100644 index 000000000..9bdcbf058 --- /dev/null +++ b/packages/tests/src/shared/videos.ts @@ -0,0 +1,323 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { expect } from 'chai' +import { pathExists } from 'fs-extra/esm' +import { readdir } from 'fs/promises' +import { basename, join } from 'path' +import { pick, uuidRegex } from '@peertube/peertube-core-utils' +import { HttpStatusCode, HttpStatusCodeType, VideoCaption, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models' +import { + loadLanguages, + VIDEO_CATEGORIES, + VIDEO_LANGUAGES, + VIDEO_LICENCES, + VIDEO_PRIVACIES +} from '@peertube/peertube-server/server/initializers/constants.js' +import { getLowercaseExtension } from '@peertube/peertube-node-utils' +import { makeRawRequest, PeerTubeServer, VideoEdit, waitJobs } from '@peertube/peertube-server-commands' +import { dateIsValid, expectStartWith, testImageGeneratedByFFmpeg } from './checks.js' +import { checkWebTorrentWorks } from './webtorrent.js' + +async function completeWebVideoFilesCheck (options: { + server: PeerTubeServer + originServer: PeerTubeServer + videoUUID: string + fixture: string + files: { + resolution: number + size?: number + }[] + objectStorageBaseUrl?: string +}) { + const { originServer, server, videoUUID, files, fixture, objectStorageBaseUrl } = options + const video = await server.videos.getWithToken({ id: videoUUID }) + const serverConfig = await originServer.config.getConfig() + const requiresAuth = video.privacy.id === VideoPrivacy.PRIVATE || video.privacy.id === VideoPrivacy.INTERNAL + + const transcodingEnabled = serverConfig.transcoding.web_videos.enabled + + for (const attributeFile of files) { + const file = video.files.find(f => f.resolution.id === attributeFile.resolution) + expect(file, `resolution ${attributeFile.resolution} does not exist`).not.to.be.undefined + + let extension = getLowercaseExtension(fixture) + // Transcoding enabled: extension will always be .mp4 + if (transcodingEnabled) extension = '.mp4' + + expect(file.id).to.exist + expect(file.magnetUri).to.have.lengthOf.above(2) + + { + const privatePath = requiresAuth + ? 'private/' + : '' + const nameReg = `${uuidRegex}-${file.resolution.id}` + + expect(file.torrentDownloadUrl).to.match(new RegExp(`${server.url}/download/torrents/${nameReg}.torrent`)) + expect(file.torrentUrl).to.match(new RegExp(`${server.url}/lazy-static/torrents/${nameReg}.torrent`)) + + if (objectStorageBaseUrl && requiresAuth) { + const regexp = new RegExp(`${originServer.url}/object-storage-proxy/web-videos/${privatePath}${nameReg}${extension}`) + expect(file.fileUrl).to.match(regexp) + } else if (objectStorageBaseUrl) { + expectStartWith(file.fileUrl, objectStorageBaseUrl) + } else { + expect(file.fileUrl).to.match(new RegExp(`${originServer.url}/static/web-videos/${privatePath}${nameReg}${extension}`)) + } + + expect(file.fileDownloadUrl).to.match(new RegExp(`${originServer.url}/download/videos/${nameReg}${extension}`)) + } + + { + const token = requiresAuth + ? server.accessToken + : undefined + + await Promise.all([ + makeRawRequest({ url: file.torrentUrl, token, expectedStatus: HttpStatusCode.OK_200 }), + makeRawRequest({ url: file.torrentDownloadUrl, token, expectedStatus: HttpStatusCode.OK_200 }), + makeRawRequest({ url: file.metadataUrl, token, expectedStatus: HttpStatusCode.OK_200 }), + makeRawRequest({ url: file.fileUrl, token, expectedStatus: HttpStatusCode.OK_200 }), + makeRawRequest({ + url: file.fileDownloadUrl, + token, + expectedStatus: objectStorageBaseUrl ? HttpStatusCode.FOUND_302 : HttpStatusCode.OK_200 + }) + ]) + } + + expect(file.resolution.id).to.equal(attributeFile.resolution) + + if (file.resolution.id === VideoResolution.H_NOVIDEO) { + expect(file.resolution.label).to.equal('Audio') + } else { + expect(file.resolution.label).to.equal(attributeFile.resolution + 'p') + } + + if (attributeFile.size) { + const minSize = attributeFile.size - ((10 * attributeFile.size) / 100) + const maxSize = attributeFile.size + ((10 * attributeFile.size) / 100) + expect( + file.size, + 'File size for resolution ' + file.resolution.label + ' outside confidence interval (' + minSize + '> size <' + maxSize + ')' + ).to.be.above(minSize).and.below(maxSize) + } + + await checkWebTorrentWorks(file.magnetUri) + } +} + +async function completeVideoCheck (options: { + server: PeerTubeServer + originServer: PeerTubeServer + videoUUID: string + attributes: { + name: string + category: number + licence: number + language: string + nsfw: boolean + commentsEnabled: boolean + downloadEnabled: boolean + description: string + publishedAt?: string + support: string + originallyPublishedAt?: string + account: { + name: string + host: string + } + isLocal: boolean + tags: string[] + privacy: number + likes?: number + dislikes?: number + duration: number + channel: { + displayName: string + name: string + description: string + isLocal: boolean + } + fixture: string + files: { + resolution: number + size: number + }[] + thumbnailfile?: string + previewfile?: string + } +}) { + const { attributes, originServer, server, videoUUID } = options + + await loadLanguages() + + const video = await server.videos.get({ id: videoUUID }) + + if (!attributes.likes) attributes.likes = 0 + if (!attributes.dislikes) attributes.dislikes = 0 + + expect(video.name).to.equal(attributes.name) + expect(video.category.id).to.equal(attributes.category) + expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Unknown') + expect(video.licence.id).to.equal(attributes.licence) + expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown') + expect(video.language.id).to.equal(attributes.language) + expect(video.language.label).to.equal(attributes.language !== null ? VIDEO_LANGUAGES[attributes.language] : 'Unknown') + expect(video.privacy.id).to.deep.equal(attributes.privacy) + expect(video.privacy.label).to.deep.equal(VIDEO_PRIVACIES[attributes.privacy]) + expect(video.nsfw).to.equal(attributes.nsfw) + expect(video.description).to.equal(attributes.description) + expect(video.account.id).to.be.a('number') + expect(video.account.host).to.equal(attributes.account.host) + expect(video.account.name).to.equal(attributes.account.name) + expect(video.channel.displayName).to.equal(attributes.channel.displayName) + expect(video.channel.name).to.equal(attributes.channel.name) + expect(video.likes).to.equal(attributes.likes) + expect(video.dislikes).to.equal(attributes.dislikes) + expect(video.isLocal).to.equal(attributes.isLocal) + expect(video.duration).to.equal(attributes.duration) + expect(video.url).to.contain(originServer.host) + expect(dateIsValid(video.createdAt)).to.be.true + expect(dateIsValid(video.publishedAt)).to.be.true + expect(dateIsValid(video.updatedAt)).to.be.true + + if (attributes.publishedAt) { + expect(video.publishedAt).to.equal(attributes.publishedAt) + } + + if (attributes.originallyPublishedAt) { + expect(video.originallyPublishedAt).to.equal(attributes.originallyPublishedAt) + } else { + expect(video.originallyPublishedAt).to.be.null + } + + expect(video.files).to.have.lengthOf(attributes.files.length) + expect(video.tags).to.deep.equal(attributes.tags) + expect(video.account.name).to.equal(attributes.account.name) + expect(video.account.host).to.equal(attributes.account.host) + expect(video.channel.displayName).to.equal(attributes.channel.displayName) + expect(video.channel.name).to.equal(attributes.channel.name) + expect(video.channel.host).to.equal(attributes.account.host) + expect(video.channel.isLocal).to.equal(attributes.channel.isLocal) + expect(video.channel.createdAt).to.exist + expect(dateIsValid(video.channel.updatedAt.toString())).to.be.true + expect(video.commentsEnabled).to.equal(attributes.commentsEnabled) + expect(video.downloadEnabled).to.equal(attributes.downloadEnabled) + + expect(video.thumbnailPath).to.exist + await testImageGeneratedByFFmpeg(server.url, attributes.thumbnailfile || attributes.fixture, video.thumbnailPath) + + if (attributes.previewfile) { + expect(video.previewPath).to.exist + await testImageGeneratedByFFmpeg(server.url, attributes.previewfile, video.previewPath) + } + + await completeWebVideoFilesCheck({ server, originServer, videoUUID: video.uuid, ...pick(attributes, [ 'fixture', 'files' ]) }) +} + +async function checkVideoFilesWereRemoved (options: { + server: PeerTubeServer + video: VideoDetails + captions?: VideoCaption[] + onlyVideoFiles?: boolean // default false +}) { + const { video, server, captions = [], onlyVideoFiles = false } = options + + const webVideoFiles = video.files || [] + const hlsFiles = video.streamingPlaylists[0]?.files || [] + + const thumbnailName = basename(video.thumbnailPath) + const previewName = basename(video.previewPath) + + const torrentNames = webVideoFiles.concat(hlsFiles).map(f => basename(f.torrentUrl)) + + const captionNames = captions.map(c => basename(c.captionPath)) + + const webVideoFilenames = webVideoFiles.map(f => basename(f.fileUrl)) + const hlsFilenames = hlsFiles.map(f => basename(f.fileUrl)) + + let directories: { [ directory: string ]: string[] } = { + videos: webVideoFilenames, + redundancy: webVideoFilenames, + [join('playlists', 'hls')]: hlsFilenames, + [join('redundancy', 'hls')]: hlsFilenames + } + + if (onlyVideoFiles !== true) { + directories = { + ...directories, + + thumbnails: [ thumbnailName ], + previews: [ previewName ], + torrents: torrentNames, + captions: captionNames + } + } + + for (const directory of Object.keys(directories)) { + const directoryPath = server.servers.buildDirectory(directory) + + const directoryExists = await pathExists(directoryPath) + if (directoryExists === false) continue + + const existingFiles = await readdir(directoryPath) + for (const existingFile of existingFiles) { + for (const shouldNotExist of directories[directory]) { + expect(existingFile, `File ${existingFile} should not exist in ${directoryPath}`).to.not.contain(shouldNotExist) + } + } + } +} + +async function saveVideoInServers (servers: PeerTubeServer[], uuid: string) { + for (const server of servers) { + server.store.videoDetails = await server.videos.get({ id: uuid }) + } +} + +function checkUploadVideoParam (options: { + server: PeerTubeServer + token: string + attributes: Partial + expectedStatus?: HttpStatusCodeType + completedExpectedStatus?: HttpStatusCodeType + mode?: 'legacy' | 'resumable' +}) { + const { server, token, attributes, completedExpectedStatus, expectedStatus, mode = 'legacy' } = options + + return mode === 'legacy' + ? server.videos.buildLegacyUpload({ token, attributes, expectedStatus: expectedStatus || completedExpectedStatus }) + : server.videos.buildResumeUpload({ + token, + attributes, + expectedStatus, + completedExpectedStatus, + path: '/api/v1/videos/upload-resumable' + }) +} + +// serverNumber starts from 1 +async function uploadRandomVideoOnServers ( + servers: PeerTubeServer[], + serverNumber: number, + additionalParams?: VideoEdit & { prefixName?: string } +) { + const server = servers.find(s => s.serverNumber === serverNumber) + const res = await server.videos.randomUpload({ wait: false, additionalParams }) + + await waitJobs(servers) + + return res +} + +// --------------------------------------------------------------------------- + +export { + completeVideoCheck, + completeWebVideoFilesCheck, + checkUploadVideoParam, + uploadRandomVideoOnServers, + checkVideoFilesWereRemoved, + saveVideoInServers +} diff --git a/packages/tests/src/shared/views.ts b/packages/tests/src/shared/views.ts new file mode 100644 index 000000000..b791eff25 --- /dev/null +++ b/packages/tests/src/shared/views.ts @@ -0,0 +1,93 @@ +import type { FfmpegCommand } from 'fluent-ffmpeg' +import { wait } from '@peertube/peertube-core-utils' +import { VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs, + waitUntilLivePublishedOnAllServers +} from '@peertube/peertube-server-commands' + +async function processViewersStats (servers: PeerTubeServer[]) { + await wait(6000) + + for (const server of servers) { + await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } }) + await server.debug.sendCommand({ body: { command: 'process-video-viewers' } }) + } + + await waitJobs(servers) +} + +async function processViewsBuffer (servers: PeerTubeServer[]) { + for (const server of servers) { + await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } }) + } + + await waitJobs(servers) +} + +async function prepareViewsServers () { + const servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: true, + transcoding: { + enabled: false + } + } + } + }) + + await doubleFollow(servers[0], servers[1]) + + return servers +} + +async function prepareViewsVideos (options: { + servers: PeerTubeServer[] + live: boolean + vod: boolean +}) { + const { servers } = options + + const liveAttributes = { + name: 'live video', + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + + let ffmpegCommand: FfmpegCommand + let live: VideoCreateResult + let vod: VideoCreateResult + + if (options.live) { + live = await servers[0].live.create({ fields: liveAttributes }) + + ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: live.uuid }) + await waitUntilLivePublishedOnAllServers(servers, live.uuid) + } + + if (options.vod) { + vod = await servers[0].videos.quickUpload({ name: 'video' }) + } + + await waitJobs(servers) + + return { liveVideoId: live?.uuid, vodVideoId: vod?.uuid, ffmpegCommand } +} + +export { + processViewersStats, + prepareViewsServers, + processViewsBuffer, + prepareViewsVideos +} diff --git a/packages/tests/src/shared/webtorrent.ts b/packages/tests/src/shared/webtorrent.ts new file mode 100644 index 000000000..1be54426a --- /dev/null +++ b/packages/tests/src/shared/webtorrent.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai' +import { readFile } from 'fs/promises' +import parseTorrent from 'parse-torrent' +import { basename, join } from 'path' +import type { Instance, Torrent } from 'webtorrent' +import { VideoFile } from '@peertube/peertube-models' +import { PeerTubeServer } from '@peertube/peertube-server-commands' + +let webtorrent: Instance + +export async function checkWebTorrentWorks (magnetUri: string, pathMatch?: RegExp) { + const torrent = await webtorrentAdd(magnetUri, true) + + expect(torrent.files).to.be.an('array') + expect(torrent.files.length).to.equal(1) + expect(torrent.files[0].path).to.exist.and.to.not.equal('') + + if (pathMatch) { + expect(torrent.files[0].path).match(pathMatch) + } +} + +export async function parseTorrentVideo (server: PeerTubeServer, file: VideoFile) { + const torrentName = basename(file.torrentUrl) + const torrentPath = server.servers.buildDirectory(join('torrents', torrentName)) + + const data = await readFile(torrentPath) + + return parseTorrent(data) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +async function webtorrentAdd (torrentId: string, refreshWebTorrent = false) { + const WebTorrent = (await import('webtorrent')).default + + if (webtorrent && refreshWebTorrent) webtorrent.destroy() + if (!webtorrent || refreshWebTorrent) webtorrent = new WebTorrent() + + webtorrent.on('error', err => console.error('Error in webtorrent', err)) + + return new Promise(res => { + const torrent = webtorrent.add(torrentId, res) + + torrent.on('error', err => console.error('Error in webtorrent torrent', err)) + torrent.on('warning', warn => { + const msg = typeof warn === 'string' + ? warn + : warn.message + + if (msg.includes('Unsupported')) return + + console.error('Warning in webtorrent torrent', warn) + }) + }) +} diff --git a/packages/tests/tsconfig.json b/packages/tests/tsconfig.json new file mode 100644 index 000000000..91a74b4be --- /dev/null +++ b/packages/tests/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "src", + "tsBuildInfoFile": "./dist/.tsbuildinfo", + "paths": { + "@tests/*": [ "src/*" ] + } + }, + "references": [ + { "path": "../core-utils" }, + { "path": "../ffmpeg" }, + { "path": "../models" }, + { "path": "../node-utils" }, + { "path": "../typescript-utils" }, + { "path": "../server-commands" }, + { "path": "../../server/tsconfig.lib.json" } + ], + "include": [ + "./src/**/*.ts" + ], + "exclude": [ + "./fixtures" + ] +} diff --git a/packages/types-generator/README.md b/packages/types-generator/README.md new file mode 100644 index 000000000..3151be344 --- /dev/null +++ b/packages/types-generator/README.md @@ -0,0 +1,19 @@ +# PeerTube typings + +These **Typescript** *types* are mainly used to write **PeerTube** plugins. + +## Installation + +Npm: +``` +npm install --save-dev @peertube/peertube-types +``` + +Yarn: +``` +yarn add --dev @peertube/peertube-types +``` + +## Usage + +> See [contribute-plugins](https://docs.joinpeertube.org/contribute/plugins#typescript) **Typescript** section of the doc. diff --git a/packages/types-generator/generate-package.ts b/packages/types-generator/generate-package.ts new file mode 100644 index 000000000..2b2f51623 --- /dev/null +++ b/packages/types-generator/generate-package.ts @@ -0,0 +1,107 @@ +import { execSync } from 'child_process' +import depcheck, { PackageDependencies } from 'depcheck' +import { readJson, remove, writeJSON } from 'fs-extra/esm' +import { copyFile, writeFile } from 'fs/promises' +import { join, resolve } from 'path' +import { currentDir, root } from '@peertube/peertube-node-utils' + +if (!process.argv[2]) { + console.error('Need version as argument') + process.exit(-1) +} + +const version = process.argv[2] +console.log('Will generate package version %s.', version) + +run() + .then(() => process.exit(0)) + .catch(err => { + console.error(err) + process.exit(-1) + }) + +async function run () { + const typesPath = currentDir(import.meta.url) + const typesDistPath = join(typesPath, 'dist') + + await remove(typesDistPath) + + const typesDistPackageJsonPath = join(typesDistPath, 'package.json') + const typesDistGitIgnorePath = join(typesDistPath, '.gitignore') + + const mainPackageJson = await readJson(join(root(), 'package.json')) + + const typesTsConfigPath = join(typesPath, 'tsconfig.types.json') + + const distTsConfigPath = join(typesPath, 'tsconfig.dist.json') + const distTsConfig = await readJson(distTsConfigPath) + + const clientPackageJson = await readJson(join(root(), 'client', 'package.json')) + + await remove(typesDistPath) + execSync(`npm run tsc -- -b ${typesTsConfigPath} --verbose`, { stdio: 'inherit' }) + execSync(`npm run resolve-tspaths -- --project ${distTsConfigPath} --src ${typesDistPath} --out ${typesDistPath}`, { stdio: 'inherit' }) + + const allDependencies = Object.assign( + mainPackageJson.dependencies, + mainPackageJson.devDependencies, + clientPackageJson.dependencies, + clientPackageJson.devDependencies + ) as PackageDependencies + + const toIgnore = Object.keys(distTsConfig?.compilerOptions?.paths || []) + + // https://github.com/depcheck/depcheck#api + const depcheckOptions = { + parsers: { '**/*.ts': depcheck.parser.typescript }, + detectors: [ + depcheck.detector.requireCallExpression, + depcheck.detector.importDeclaration + ], + ignoreMatches: toIgnore, + package: { dependencies: allDependencies } + } + + const result = await depcheck(typesDistPath, depcheckOptions) + + if (Object.keys(result.invalidDirs).length !== 0) { + console.error('Invalid directories detected.', { invalidDirs: result.invalidDirs }) + process.exit(-1) + } + + if (Object.keys(result.invalidFiles).length !== 0) { + console.error('Invalid files detected.', { invalidFiles: result.invalidFiles }) + process.exit(-1) + } + + const unusedDependencies = result.dependencies + + console.log(`Removing ${Object.keys(unusedDependencies).length} unused dependencies.`) + const dependencies = Object + .keys(allDependencies) + .filter(dependencyName => !unusedDependencies.includes(dependencyName) && !toIgnore.includes(dependencyName)) + .reduce((dependencies, dependencyName) => { + dependencies[dependencyName] = allDependencies[dependencyName] + return dependencies + }, {}) + + const { description, licence, engines, author, repository } = mainPackageJson + const typesPackageJson = { + name: '@peertube/peertube-types', + description, + version, + private: false, + license: licence, + engines, + author, + repository, + dependencies + } + console.log(`Writing package.json to ${typesDistPackageJsonPath}`) + await writeJSON(typesDistPackageJsonPath, typesPackageJson, { spaces: 2 }) + + console.log(`Writing git ignore to ${typesDistGitIgnorePath}`) + await writeFile(typesDistGitIgnorePath, '*.tsbuildinfo') + + await copyFile(resolve(typesPath, './README.md'), resolve(typesDistPath, './README.md')) +} diff --git a/packages/types-generator/package.json b/packages/types-generator/package.json new file mode 100644 index 000000000..a72235851 --- /dev/null +++ b/packages/types-generator/package.json @@ -0,0 +1,9 @@ +{ + "name": "@peertube/peertube-types-generator", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": { + "@peertube/peertube-core-utils": "*" + } +} diff --git a/packages/types-generator/src/client/index.ts b/packages/types-generator/src/client/index.ts new file mode 100644 index 000000000..8868dd5b0 --- /dev/null +++ b/packages/types-generator/src/client/index.ts @@ -0,0 +1 @@ +export * from '@client/types/index.js' diff --git a/packages/types-generator/src/client/tsconfig.types.json b/packages/types-generator/src/client/tsconfig.types.json new file mode 100644 index 000000000..f60b43f07 --- /dev/null +++ b/packages/types-generator/src/client/tsconfig.types.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "stripInternal": true, + "removeComments": false, + "emitDeclarationOnly": true, + "outDir": "../../dist/client/", + "rootDir": "./", + "baseUrl": "./", + "tsBuildInfoFile": "../../dist/tsconfig.client.types.tsbuildinfo", + "paths": { + "@client/*": [ "../../../../client/src/*" ] + } + }, + "references": [ + { "path": "../../../../client/tsconfig.types.json" } + ], + "files": [ "./index.ts" ] +} diff --git a/packages/types-generator/src/index.ts b/packages/types-generator/src/index.ts new file mode 100644 index 000000000..b5669ea24 --- /dev/null +++ b/packages/types-generator/src/index.ts @@ -0,0 +1,3 @@ +export * from '@server/types/index.js' +export * from '@server/types/models/index.js' +export * from '@peertube/peertube-models' diff --git a/packages/types-generator/tests/test.ts b/packages/types-generator/tests/test.ts new file mode 100644 index 000000000..bfdcdeed5 --- /dev/null +++ b/packages/types-generator/tests/test.ts @@ -0,0 +1,32 @@ +import { RegisterServerOptions, Video } from '../dist/index.js' +import { RegisterClientOptions } from '../dist/client/index.js' + +function register1 ({ registerHook }: RegisterServerOptions) { + registerHook({ + target: 'action:application.listening', + handler: () => console.log('hello') + }) +} + +function register2 ({ registerHook, peertubeHelpers }: RegisterClientOptions) { + registerHook({ + target: 'action:admin-plugin-settings.init', + handler: ({ npmName }: { npmName: string }) => { + if ('peertube-plugin-transcription' !== npmName) { + return + } + }, + }) + + registerHook({ + target: 'action:video-watch.video.loaded', + handler: ({ video }: { video: Video }) => { + fetch(`${peertubeHelpers.getBaseRouterRoute()}/videos/${video.uuid}/captions`, { + method: 'PUT', + headers: peertubeHelpers.getAuthHeader(), + }) + .then((res) => res.json()) + .then((data) => console.log('Hi %s.', data)) + }, + }) +} diff --git a/packages/types-generator/tsconfig.dist.json b/packages/types-generator/tsconfig.dist.json new file mode 100644 index 000000000..6c24a67ba --- /dev/null +++ b/packages/types-generator/tsconfig.dist.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "typeRoots": [ + "node_modules/@types", + "client/node_modules/@types" + ], + "baseUrl": "./dist", + "paths": { + "@server/*": [ "server/server/*" ], + "@client/*": [ "client/*" ], + "@peertube/peertube-models": [ "peertube-models" ], + "@peertube/peertube-typescript-utils": [ "peertube-typescript-utils" ] + } + } +} + diff --git a/packages/types-generator/tsconfig.json b/packages/types-generator/tsconfig.json new file mode 100644 index 000000000..fe09d9395 --- /dev/null +++ b/packages/types-generator/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "files": [ "./generate-package.ts" ], + "references": [ + { "path": "../node-utils" } + ] +} diff --git a/packages/types-generator/tsconfig.types.json b/packages/types-generator/tsconfig.types.json new file mode 100644 index 000000000..a3a1b7c0d --- /dev/null +++ b/packages/types-generator/tsconfig.types.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "stripInternal": true, + "removeComments": false, + "emitDeclarationOnly": true, + "sourceMap": false, + "outDir": "./dist/", + "baseUrl": "./", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/tsconfig.server.types.tsbuildinfo", + "paths": { + "@server/*": [ "../../server/server/*" ] + } + }, + "references": [ + { "path": "../models/tsconfig.types.json" }, + { "path": "../typescript-utils/tsconfig.types.json" }, + { "path": "../../server/tsconfig.types.json" }, + { "path": "./src/client/tsconfig.types.json" } + ], + "files": ["./src/index.ts"] +} diff --git a/packages/types/README.md b/packages/types/README.md deleted file mode 100644 index 3151be344..000000000 --- a/packages/types/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# PeerTube typings - -These **Typescript** *types* are mainly used to write **PeerTube** plugins. - -## Installation - -Npm: -``` -npm install --save-dev @peertube/peertube-types -``` - -Yarn: -``` -yarn add --dev @peertube/peertube-types -``` - -## Usage - -> See [contribute-plugins](https://docs.joinpeertube.org/contribute/plugins#typescript) **Typescript** section of the doc. diff --git a/packages/types/generate-package.ts b/packages/types/generate-package.ts deleted file mode 100644 index 125259bb4..000000000 --- a/packages/types/generate-package.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { execSync } from 'child_process' -import depcheck, { PackageDependencies } from 'depcheck' -import { copyFile, readJson, remove, writeFile, writeJSON } from 'fs-extra' -import { join, resolve } from 'path' -import { root } from '../../shared/core-utils' - -if (!process.argv[2]) { - console.error('Need version as argument') - process.exit(-1) -} - -const version = process.argv[2] -console.log('Will generate package version %s.', version) - -run() - .then(() => process.exit(0)) - .catch(err => { - console.error(err) - process.exit(-1) - }) - -async function run () { - const typesPath = __dirname - const typesDistPath = join(typesPath, 'dist') - const typesDistPackageJsonPath = join(typesDistPath, 'package.json') - const typesDistGitIgnorePath = join(typesDistPath, '.gitignore') - const mainPackageJson = await readJson(join(root(), 'package.json')) - const distTsConfigPath = join(typesPath, 'tsconfig.dist.json') - const distTsConfig = await readJson(distTsConfigPath) - const clientPackageJson = await readJson(join(root(), 'client', 'package.json')) - - await remove(typesDistPath) - execSync('npm run tsc -- -b --verbose packages/types', { stdio: 'inherit' }) - execSync(`npm run resolve-tspaths -- --project ${distTsConfigPath} --src ${typesDistPath} --out ${typesDistPath}`, { stdio: 'inherit' }) - - const allDependencies = Object.assign( - mainPackageJson.dependencies, - mainPackageJson.devDependencies, - clientPackageJson.dependencies, - clientPackageJson.devDependencies - ) as PackageDependencies - - // https://github.com/depcheck/depcheck#api - const depcheckOptions = { - parsers: { '**/*.ts': depcheck.parser.typescript }, - detectors: [ - depcheck.detector.requireCallExpression, - depcheck.detector.importDeclaration - ], - ignoreMatches: Object.keys(distTsConfig?.compilerOptions?.paths || []), - package: { dependencies: allDependencies } - } - - const result = await depcheck(typesDistPath, depcheckOptions) - - if (Object.keys(result.invalidDirs).length !== 0) { - console.error('Invalid directories detected.', { invalidDirs: result.invalidDirs }) - process.exit(-1) - } - - if (Object.keys(result.invalidFiles).length !== 0) { - console.error('Invalid files detected.', { invalidFiles: result.invalidFiles }) - process.exit(-1) - } - - const unusedDependencies = result.dependencies - - console.log(`Removing ${Object.keys(unusedDependencies).length} unused dependencies.`) - const dependencies = Object - .keys(allDependencies) - .filter(dependencyName => !unusedDependencies.includes(dependencyName)) - .reduce((dependencies, dependencyName) => { - dependencies[dependencyName] = allDependencies[dependencyName] - return dependencies - }, {}) - - const { description, licence, engines, author, repository } = mainPackageJson - const typesPackageJson = { - name: '@peertube/peertube-types', - description, - version, - private: false, - license: licence, - engines, - author, - repository, - dependencies - } - console.log(`Writing package.json to ${typesDistPackageJsonPath}`) - await writeJSON(typesDistPackageJsonPath, typesPackageJson, { spaces: 2 }) - - console.log(`Writing git ignore to ${typesDistGitIgnorePath}`) - await writeFile(typesDistGitIgnorePath, '*.tsbuildinfo') - - await copyFile(resolve(typesPath, './README.md'), resolve(typesDistPath, './README.md')) -} diff --git a/packages/types/src/client/index.ts b/packages/types/src/client/index.ts deleted file mode 100644 index 5ee10ecb8..000000000 --- a/packages/types/src/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@client/types' diff --git a/packages/types/src/client/tsconfig.json b/packages/types/src/client/tsconfig.json deleted file mode 100644 index bb76fbe21..000000000 --- a/packages/types/src/client/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../../../tsconfig.base.json", - "compilerOptions": { - "stripInternal": true, - "removeComments": false, - "emitDeclarationOnly": true, - "outDir": "../../dist/client/", - "rootDir": "./", - "tsBuildInfoFile": "../../dist/tsconfig.client.types.tsbuildinfo" - }, - "references": [ - { "path": "../../../../client/tsconfig.types.json" } - ], - "files": ["index.ts"] -} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts deleted file mode 100644 index a8adca287..000000000 --- a/packages/types/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from '@server/types' -export * from '@server/types/models' -export * from '@shared/models' diff --git a/packages/types/tests/test.ts b/packages/types/tests/test.ts deleted file mode 100644 index 8c53320a1..000000000 --- a/packages/types/tests/test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { RegisterServerOptions, Video } from '../dist' -import { RegisterClientOptions } from '../dist/client' - -function register1 ({ registerHook }: RegisterServerOptions) { - registerHook({ - target: 'action:application.listening', - handler: () => console.log('hello') - }) -} - -function register2 ({ registerHook, peertubeHelpers }: RegisterClientOptions) { - registerHook({ - target: 'action:admin-plugin-settings.init', - handler: ({ npmName }: { npmName: string }) => { - if ('peertube-plugin-transcription' !== npmName) { - return - } - }, - }) - - registerHook({ - target: 'action:video-watch.video.loaded', - handler: ({ video }: { video: Video }) => { - fetch(`${peertubeHelpers.getBaseRouterRoute()}/videos/${video.uuid}/captions`, { - method: 'PUT', - headers: peertubeHelpers.getAuthHeader(), - }) - .then((res) => res.json()) - .then((data) => console.log('Hi %s.', data)) - }, - }) -} diff --git a/packages/types/tsconfig.dist.json b/packages/types/tsconfig.dist.json deleted file mode 100644 index fbc92712b..000000000 --- a/packages/types/tsconfig.dist.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "typeRoots": [ - "node_modules/@types", - "client/node_modules/@types" - ], - "baseUrl": "./dist", - "paths": { - "@server/*": [ "server/*" ], - "@shared/*": [ "shared/*" ], - "@client/*": [ "client/*" ] - } - } -} - diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json deleted file mode 100644 index f8e16f6b4..000000000 --- a/packages/types/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "stripInternal": true, - "removeComments": false, - "emitDeclarationOnly": true, - "outDir": "./dist/", - "baseUrl": "./src/", - "rootDir": "./src/", - "tsBuildInfoFile": "./dist/tsconfig.server.types.tsbuildinfo", - "paths": { - "@server/*": [ "../../../server/*" ], - "@shared/*": [ "../../../shared/*" ], - "@client/*": [ "../../../client/src/*" ] - } - }, - "references": [ - { "path": "../../shared/tsconfig.types.json" }, - { "path": "../../server/tsconfig.types.json" }, - { "path": "./src/client/tsconfig.json" } - ], - "files": ["./src/index.ts"] -} diff --git a/packages/typescript-utils/package.json b/packages/typescript-utils/package.json new file mode 100644 index 000000000..9608bb018 --- /dev/null +++ b/packages/typescript-utils/package.json @@ -0,0 +1,19 @@ +{ + "name": "@peertube/peertube-typescript-utils", + "private": true, + "version": "0.0.0", + "main": "dist/index.js", + "files": [ "dist" ], + "exports": { + "types": "./dist/index.d.ts", + "peertube:tsx": "./src/index.ts", + "default": "./dist/index.js" + }, + "type": "module", + "devDependencies": {}, + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "dependencies": {} +} diff --git a/packages/typescript-utils/src/index.ts b/packages/typescript-utils/src/index.ts new file mode 100644 index 000000000..fdc633235 --- /dev/null +++ b/packages/typescript-utils/src/index.ts @@ -0,0 +1 @@ +export * from './types.js' diff --git a/packages/typescript-utils/src/types.ts b/packages/typescript-utils/src/types.ts new file mode 100644 index 000000000..57cc23f1f --- /dev/null +++ b/packages/typescript-utils/src/types.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/array-type */ + +export type FunctionPropertyNames = { + [K in keyof T]: T[K] extends Function ? K : never +}[keyof T] + +export type FunctionProperties = Pick> + +export type AttributesOnly = { + [K in keyof T]: T[K] extends Function ? never : T[K] +} + +export type PickWith = { + [P in KT]: T[P] extends V ? V : never +} + +export type PickWithOpt = { + [P in KT]?: T[P] extends V ? V : never +} + +// https://github.com/krzkaczor/ts-essentials Rocks! +export type DeepPartial = { + [P in keyof T]?: T[P] extends Array + ? Array> + : T[P] extends ReadonlyArray + ? ReadonlyArray> + : DeepPartial +} + +type Primitive = string | Function | number | boolean | symbol | undefined | null +export type DeepOmitHelper = { + [P in K]: // extra level of indirection needed to trigger homomorhic behavior + T[P] extends infer TP // distribute over unions + ? TP extends Primitive + ? TP // leave primitives and functions alone + : TP extends any[] + ? DeepOmitArray // Array special handling + : DeepOmit + : never +} +export type DeepOmit = T extends Primitive ? T : DeepOmitHelper> + +export type DeepOmitArray = { + [P in keyof T]: DeepOmit +} diff --git a/packages/typescript-utils/tsconfig.json b/packages/typescript-utils/tsconfig.json new file mode 100644 index 000000000..58fa2330b --- /dev/null +++ b/packages/typescript-utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + } +} diff --git a/packages/typescript-utils/tsconfig.types.json b/packages/typescript-utils/tsconfig.types.json new file mode 100644 index 000000000..e666b4ca2 --- /dev/null +++ b/packages/typescript-utils/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../types-generator/dist/peertube-typescript-utils", + "tsBuildInfoFile": "../types-generator/dist/peertube-typescript-utils/.tsbuildinfo", + "stripInternal": true, + "removeComments": false, + "emitDeclarationOnly": true + } +} -- cgit v1.2.3