aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/test.yml1
-rw-r--r--client/.gitignore1
-rw-r--r--client/e2e/src/po/admin-config.po.ts27
-rw-r--r--client/e2e/src/po/admin-registration.po.ts35
-rw-r--r--client/e2e/src/po/login.po.ts36
-rw-r--r--client/e2e/src/po/signup.po.ts51
-rw-r--r--client/e2e/src/suites-local/signup.e2e-spec.ts403
-rw-r--r--client/e2e/src/utils/elements.ts17
-rw-r--r--client/e2e/src/utils/email.ts31
-rw-r--r--client/e2e/src/utils/hooks.ts24
-rw-r--r--client/e2e/src/utils/index.ts2
-rw-r--r--client/e2e/src/utils/mock-smtp.ts58
-rw-r--r--client/e2e/src/utils/server.ts4
-rw-r--r--client/e2e/wdio.local-test.conf.ts2
-rw-r--r--client/e2e/wdio.local.conf.ts2
-rw-r--r--client/package.json6
-rw-r--r--client/src/app/+about/about-instance/about-instance.component.html40
-rw-r--r--client/src/app/+about/about-instance/about-instance.component.ts41
-rw-r--r--client/src/app/+about/about-instance/about-instance.resolver.ts25
-rw-r--r--client/src/app/+admin/admin.component.ts12
-rw-r--r--client/src/app/+admin/admin.module.ts16
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html25
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts1
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html2
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html2
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.html4
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.ts19
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.html4
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.ts19
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts2
-rw-r--r--client/src/app/+admin/moderation/index.ts1
-rw-r--r--client/src/app/+admin/moderation/moderation.routes.ts15
-rw-r--r--client/src/app/+admin/moderation/registration-list/admin-registration.service.ts81
-rw-r--r--client/src/app/+admin/moderation/registration-list/index.ts4
-rw-r--r--client/src/app/+admin/moderation/registration-list/process-registration-modal.component.html74
-rw-r--r--client/src/app/+admin/moderation/registration-list/process-registration-modal.component.scss3
-rw-r--r--client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts122
-rw-r--r--client/src/app/+admin/moderation/registration-list/process-registration-validators.ts11
-rw-r--r--client/src/app/+admin/moderation/registration-list/registration-list.component.html135
-rw-r--r--client/src/app/+admin/moderation/registration-list/registration-list.component.scss7
-rw-r--r--client/src/app/+admin/moderation/registration-list/registration-list.component.ts151
-rw-r--r--client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts27
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.html4
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.ts15
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.html23
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.scss10
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.ts17
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.html4
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.ts46
-rw-r--r--client/src/app/+admin/shared/shared-admin.module.ts7
-rw-r--r--client/src/app/+admin/shared/user-email-info.component.html13
-rw-r--r--client/src/app/+admin/shared/user-email-info.component.scss10
-rw-r--r--client/src/app/+admin/shared/user-email-info.component.ts20
-rw-r--r--client/src/app/+admin/system/jobs/job.service.ts2
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.ts4
-rw-r--r--client/src/app/+login/login.component.ts33
-rw-r--r--client/src/app/+my-library/my-ownership/my-ownership.component.scss4
-rw-r--r--client/src/app/+my-library/my-ownership/my-ownership.component.ts2
-rw-r--r--client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts2
-rw-r--r--client/src/app/+my-library/my-video-imports/my-video-imports.component.ts2
-rw-r--r--client/src/app/+signup/+register/register.component.html35
-rw-r--r--client/src/app/+signup/+register/register.component.ts62
-rw-r--r--client/src/app/+signup/+register/shared/index.ts1
-rw-r--r--client/src/app/+signup/+register/shared/register-validators.ts18
-rw-r--r--client/src/app/+signup/+register/steps/register-step-about.component.html4
-rw-r--r--client/src/app/+signup/+register/steps/register-step-about.component.ts1
-rw-r--r--client/src/app/+signup/+register/steps/register-step-channel.component.ts6
-rw-r--r--client/src/app/+signup/+register/steps/register-step-terms.component.html14
-rw-r--r--client/src/app/+signup/+register/steps/register-step-terms.component.ts10
-rw-r--r--client/src/app/+signup/+register/steps/register-step-user.component.ts6
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts6
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html17
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts86
-rw-r--r--client/src/app/+signup/shared/shared-signup.module.ts11
-rw-r--r--client/src/app/+signup/shared/signup-success-after-email.component.html21
-rw-r--r--client/src/app/+signup/shared/signup-success-after-email.component.ts10
-rw-r--r--client/src/app/+signup/shared/signup-success-before-email.component.html35
-rw-r--r--client/src/app/+signup/shared/signup-success-before-email.component.ts12
-rw-r--r--client/src/app/+signup/shared/signup-success.component.html22
-rw-r--r--client/src/app/+signup/shared/signup-success.component.ts19
-rw-r--r--client/src/app/+signup/shared/signup.service.ts (renamed from client/src/app/shared/shared-users/user-signup.service.ts)41
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts40
-rw-r--r--client/src/app/+videos/video-list/videos-list-common-page.component.ts3
-rw-r--r--client/src/app/core/auth/auth.service.ts45
-rw-r--r--client/src/app/core/renderer/linkifier.service.ts2
-rw-r--r--client/src/app/core/renderer/markdown.service.ts10
-rw-r--r--client/src/app/core/rest/rest-extractor.service.ts6
-rw-r--r--client/src/app/core/rest/rest-table.ts16
-rw-r--r--client/src/app/menu/menu.component.html4
-rw-r--r--client/src/app/menu/menu.component.ts12
-rw-r--r--client/src/app/shared/form-validators/form-validator.model.ts2
-rw-r--r--client/src/app/shared/form-validators/user-validators.ts7
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-details.component.html6
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts2
-rw-r--r--client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts20
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.ts4
-rw-r--r--client/src/app/shared/shared-instance/instance-features-table.component.html7
-rw-r--r--client/src/app/shared/shared-instance/instance-features-table.component.ts9
-rw-r--r--client/src/app/shared/shared-instance/instance.service.ts7
-rw-r--r--client/src/app/shared/shared-main/account/index.ts1
-rw-r--r--client/src/app/shared/shared-main/account/signup-label.component.html2
-rw-r--r--client/src/app/shared/shared-main/account/signup-label.component.ts9
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts6
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.model.ts12
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.html8
-rw-r--r--client/src/app/shared/shared-moderation/account-blocklist.component.scss4
-rw-r--r--client/src/app/shared/shared-moderation/account-blocklist.component.ts2
-rw-r--r--client/src/app/shared/shared-moderation/moderation.scss4
-rw-r--r--client/src/app/shared/shared-moderation/server-blocklist.component.scss4
-rw-r--r--client/src/app/shared/shared-moderation/server-blocklist.component.ts2
-rw-r--r--client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts2
-rw-r--r--client/src/app/shared/shared-users/index.ts1
-rw-r--r--client/src/app/shared/shared-users/shared-users.module.ts3
-rw-r--r--client/src/app/shared/shared-users/user-admin.service.ts2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.html4
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.scss4
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts2
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.ts60
-rw-r--r--client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts4
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist.service.ts16
-rw-r--r--client/src/assets/player/peertube-player-manager.ts7
-rw-r--r--client/src/assets/player/shared/control-bar/index.ts1
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-live-display.ts93
-rw-r--r--client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts76
-rw-r--r--client/src/assets/player/shared/manager-options/control-bar-options-builder.ts25
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts4
-rw-r--r--client/src/assets/player/shared/stats/stats-card.ts4
-rw-r--r--client/src/assets/player/types/manager-options.ts2
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts3
-rw-r--r--client/src/root-helpers/logger.ts8
-rw-r--r--client/src/root-helpers/plugins-manager.ts11
-rw-r--r--client/src/sass/class-helpers.scss6
-rw-r--r--client/src/sass/include/_badges.scss4
-rw-r--r--client/src/sass/include/_fonts.scss4
-rw-r--r--client/src/sass/include/_mixins.scss36
-rw-r--r--client/src/sass/player/control-bar.scss21
-rw-r--r--client/src/sass/primeng-custom.scss1
-rw-r--r--client/src/standalone/videos/shared/player-manager-options.ts4
-rw-r--r--client/yarn.lock463
-rw-r--r--config/default.yaml11
-rw-r--r--config/dev.yaml5
-rw-r--r--config/production.yaml.example11
-rw-r--r--config/test.yaml1
-rw-r--r--package.json2
-rwxr-xr-xscripts/i18n/create-custom-files.ts2
-rw-r--r--server.ts5
-rw-r--r--server/controllers/activitypub/client.ts2
-rw-r--r--server/controllers/api/config.ts1
-rw-r--r--server/controllers/api/users/email-verification.ts72
-rw-r--r--server/controllers/api/users/index.ts99
-rw-r--r--server/controllers/api/users/registrations.ts249
-rw-r--r--server/controllers/api/video-playlist.ts7
-rw-r--r--server/controllers/api/videos/comment.ts7
-rw-r--r--server/controllers/api/videos/token.ts2
-rw-r--r--server/controllers/feeds.ts4
-rw-r--r--server/controllers/tracker.ts34
-rw-r--r--server/helpers/custom-validators/misc.ts8
-rw-r--r--server/helpers/custom-validators/user-registration.ts25
-rw-r--r--server/helpers/custom-validators/video-captions.ts9
-rw-r--r--server/helpers/custom-validators/video-imports.ts9
-rw-r--r--server/helpers/decache.ts2
-rw-r--r--server/helpers/memoize.ts12
-rw-r--r--server/helpers/youtube-dl/youtube-dl-cli.ts12
-rw-r--r--server/initializers/checker-after-init.ts15
-rw-r--r--server/initializers/checker-before-init.ts3
-rw-r--r--server/initializers/config.ts7
-rw-r--r--server/initializers/constants.ts31
-rw-r--r--server/initializers/database.ts6
-rw-r--r--server/initializers/installer.ts6
-rw-r--r--server/initializers/migrations/0750-user-registration.ts58
-rw-r--r--server/initializers/migrations/0755-unique-viewer-url.ts27
-rw-r--r--server/lib/auth/external-auth.ts72
-rw-r--r--server/lib/auth/oauth-model.ts75
-rw-r--r--server/lib/auth/oauth.ts43
-rw-r--r--server/lib/auth/tokens-cache.ts8
-rw-r--r--server/lib/emailer.ts54
-rw-r--r--server/lib/emails/common/base.pug12
-rw-r--r--server/lib/emails/user-registration-request-accepted/html.pug10
-rw-r--r--server/lib/emails/user-registration-request-rejected/html.pug9
-rw-r--r--server/lib/emails/user-registration-request/html.pug9
-rw-r--r--server/lib/emails/verify-email/html.pug26
-rw-r--r--server/lib/job-queue/job-queue.ts2
-rw-r--r--server/lib/notifier/notifier.ts19
-rw-r--r--server/lib/notifier/shared/instance/direct-registration-for-moderators.ts (renamed from server/lib/notifier/shared/instance/registration-for-moderators.ts)4
-rw-r--r--server/lib/notifier/shared/instance/index.ts3
-rw-r--r--server/lib/notifier/shared/instance/registration-request-for-moderators.ts48
-rw-r--r--server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts51
-rw-r--r--server/lib/opentelemetry/metric-helpers/index.ts1
-rw-r--r--server/lib/opentelemetry/metrics.ts6
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts6
-rw-r--r--server/lib/redis.ts45
-rw-r--r--server/lib/server-config-manager.ts12
-rw-r--r--server/lib/signup.ts15
-rw-r--r--server/lib/sync-channel.ts2
-rw-r--r--server/lib/user.ts38
-rw-r--r--server/lib/video-comment.ts33
-rw-r--r--server/lib/video-tokens-manager.ts22
-rw-r--r--server/middlewares/sort.ts23
-rw-r--r--server/middlewares/validators/config.ts1
-rw-r--r--server/middlewares/validators/index.ts2
-rw-r--r--server/middlewares/validators/shared/user-registrations.ts60
-rw-r--r--server/middlewares/validators/shared/users.ts4
-rw-r--r--server/middlewares/validators/shared/videos.ts12
-rw-r--r--server/middlewares/validators/sort.ts95
-rw-r--r--server/middlewares/validators/user-email-verification.ts94
-rw-r--r--server/middlewares/validators/user-registrations.ts208
-rw-r--r--server/middlewares/validators/users.ts151
-rw-r--r--server/models/abuse/abuse-message.ts2
-rw-r--r--server/models/abuse/abuse.ts4
-rw-r--r--server/models/abuse/sql/abuse-query-builder.ts (renamed from server/models/abuse/abuse-query-builder.ts)4
-rw-r--r--server/models/account/account-blocklist.ts2
-rw-r--r--server/models/account/account-video-rate.ts2
-rw-r--r--server/models/account/account.ts16
-rw-r--r--server/models/actor/actor-follow.ts16
-rw-r--r--server/models/actor/actor-image.ts14
-rw-r--r--server/models/actor/actor.ts27
-rw-r--r--server/models/actor/sql/instance-list-followers-query-builder.ts2
-rw-r--r--server/models/actor/sql/instance-list-following-query-builder.ts2
-rw-r--r--server/models/actor/sql/shared/actor-follow-table-attributes.ts65
-rw-r--r--server/models/actor/sql/shared/instance-list-follows-query-builder.ts2
-rw-r--r--server/models/redundancy/video-redundancy.ts2
-rw-r--r--server/models/server/plugin.ts2
-rw-r--r--server/models/server/server-blocklist.ts2
-rw-r--r--server/models/server/server.ts14
-rw-r--r--server/models/shared/index.ts4
-rw-r--r--server/models/shared/model-builder.ts27
-rw-r--r--server/models/shared/model-cache.ts (renamed from server/models/model-cache.ts)0
-rw-r--r--server/models/shared/query.ts75
-rw-r--r--server/models/shared/sequelize-helpers.ts39
-rw-r--r--server/models/shared/sort.ts146
-rw-r--r--server/models/shared/sql.ts68
-rw-r--r--server/models/shared/update.ts14
-rw-r--r--server/models/user/sql/user-notitication-list-query-builder.ts132
-rw-r--r--server/models/user/user-notification-setting.ts2
-rw-r--r--server/models/user/user-notification.ts28
-rw-r--r--server/models/user/user-registration.ts259
-rw-r--r--server/models/user/user.ts21
-rw-r--r--server/models/utils.ts317
-rw-r--r--server/models/video/formatter/video-format-utils.ts2
-rw-r--r--server/models/video/sql/comment/video-comment-list-query-builder.ts400
-rw-r--r--server/models/video/sql/comment/video-comment-table-attributes.ts43
-rw-r--r--server/models/video/sql/video/shared/abstract-video-query-builder.ts2
-rw-r--r--server/models/video/sql/video/videos-id-list-query-builder.ts7
-rw-r--r--server/models/video/tag.ts2
-rw-r--r--server/models/video/video-blacklist.ts6
-rw-r--r--server/models/video/video-caption.ts2
-rw-r--r--server/models/video/video-change-ownership.ts2
-rw-r--r--server/models/video/video-channel-sync.ts2
-rw-r--r--server/models/video/video-channel.ts12
-rw-r--r--server/models/video/video-comment.ts458
-rw-r--r--server/models/video/video-file.ts13
-rw-r--r--server/models/video/video-import.ts2
-rw-r--r--server/models/video/video-playlist-element.ts41
-rw-r--r--server/models/video/video-playlist.ts12
-rw-r--r--server/models/video/video-share.ts2
-rw-r--r--server/models/video/video-streaming-playlist.ts7
-rw-r--r--server/models/video/video.ts7
-rw-r--r--server/models/view/local-video-viewer.ts4
-rw-r--r--server/tests/api/activitypub/cleaner.ts8
-rw-r--r--server/tests/api/check-params/config.ts2
-rw-r--r--server/tests/api/check-params/contact-form.ts13
-rw-r--r--server/tests/api/check-params/index.ts3
-rw-r--r--server/tests/api/check-params/redundancy.ts2
-rw-r--r--server/tests/api/check-params/registrations.ts433
-rw-r--r--server/tests/api/check-params/upload-quota.ts4
-rw-r--r--server/tests/api/check-params/users-admin.ts9
-rw-r--r--server/tests/api/check-params/users-emails.ts119
-rw-r--r--server/tests/api/check-params/users.ts255
-rw-r--r--server/tests/api/live/live-fast-restream.ts14
-rw-r--r--server/tests/api/notifications/index.ts1
-rw-r--r--server/tests/api/notifications/moderation-notifications.ts55
-rw-r--r--server/tests/api/notifications/registrations-notifications.ts88
-rw-r--r--server/tests/api/object-storage/video-static-file-privacy.ts4
-rw-r--r--server/tests/api/server/config-defaults.ts4
-rw-r--r--server/tests/api/server/config.ts9
-rw-r--r--server/tests/api/server/contact-form.ts9
-rw-r--r--server/tests/api/server/email.ts77
-rw-r--r--server/tests/api/server/reverse-proxy.ts8
-rw-r--r--server/tests/api/users/index.ts4
-rw-r--r--server/tests/api/users/oauth.ts192
-rw-r--r--server/tests/api/users/registrations.ts415
-rw-r--r--server/tests/api/users/users-email-verification.ts (renamed from server/tests/api/users/users-verification.ts)39
-rw-r--r--server/tests/api/users/users.ts234
-rw-r--r--server/tests/api/videos/video-channel-syncs.ts3
-rw-r--r--server/tests/api/videos/video-comments.ts33
-rw-r--r--server/tests/api/videos/video-imports.ts2
-rw-r--r--server/tests/api/videos/video-playlists.ts15
-rw-r--r--server/tests/external-plugins/akismet.ts4
-rw-r--r--server/tests/external-plugins/auto-block-videos.ts2
-rw-r--r--server/tests/external-plugins/auto-mute.ts2
-rw-r--r--server/tests/feeds/feeds.ts8
-rw-r--r--server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js12
-rw-r--r--server/tests/fixtures/peertube-plugin-test-four/main.js6
-rw-r--r--server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js13
-rw-r--r--server/tests/fixtures/peertube-plugin-test/main.js34
-rw-r--r--server/tests/helpers/index.ts1
-rw-r--r--server/tests/helpers/version.ts36
-rw-r--r--server/tests/plugins/action-hooks.ts2
-rw-r--r--server/tests/plugins/external-auth.ts42
-rw-r--r--server/tests/plugins/filter-hooks.ts96
-rw-r--r--server/tests/plugins/id-and-pass-auth.ts34
-rw-r--r--server/tests/plugins/plugin-helpers.ts12
-rw-r--r--server/tests/shared/notifications.ts45
-rw-r--r--server/tests/shared/videos.ts2
-rw-r--r--server/types/express.d.ts8
-rw-r--r--server/types/lib.d.ts12
-rw-r--r--server/types/models/user/index.ts1
-rw-r--r--server/types/models/user/user-notification.ts9
-rw-r--r--server/types/models/user/user-registration.ts15
-rw-r--r--server/types/plugins/register-server-auth.model.ts21
-rw-r--r--server/types/plugins/register-server-option.model.ts3
-rw-r--r--shared/core-utils/common/version.ts17
-rw-r--r--shared/core-utils/plugins/hooks.ts8
-rw-r--r--shared/core-utils/renderer/html.ts6
-rw-r--r--shared/core-utils/users/user-role.ts3
-rw-r--r--shared/models/plugins/server/server-hook.model.ts7
-rw-r--r--shared/models/server/custom-config.model.ts1
-rw-r--r--shared/models/server/server-config.model.ts1
-rw-r--r--shared/models/server/server-error-code.enum.ts10
-rw-r--r--shared/models/users/index.ts2
-rw-r--r--shared/models/users/registration/index.ts5
-rw-r--r--shared/models/users/registration/user-register.model.ts (renamed from shared/models/users/user-register.model.ts)0
-rw-r--r--shared/models/users/registration/user-registration-request.model.ts5
-rw-r--r--shared/models/users/registration/user-registration-state.model.ts5
-rw-r--r--shared/models/users/registration/user-registration-update-state.model.ts4
-rw-r--r--shared/models/users/registration/user-registration.model.ts29
-rw-r--r--shared/models/users/user-notification.model.ts9
-rw-r--r--shared/models/users/user-right.enum.ts4
-rw-r--r--shared/server-commands/miscs/sql-command.ts102
-rw-r--r--shared/server-commands/requests/requests.ts2
-rw-r--r--shared/server-commands/server/config-command.ts50
-rw-r--r--shared/server-commands/server/server.ts3
-rw-r--r--shared/server-commands/users/index.ts1
-rw-r--r--shared/server-commands/users/registrations-command.ts151
-rw-r--r--shared/server-commands/users/users-command.ts29
-rw-r--r--support/doc/api/openapi.yaml297
-rw-r--r--support/doc/dependencies.md2
-rw-r--r--support/doc/docker.md2
-rw-r--r--support/doc/plugins/guide.md22
-rw-r--r--support/doc/production.md34
-rw-r--r--tsconfig.json3
-rw-r--r--yarn.lock2
347 files changed, 8009 insertions, 2986 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 65e1acec6..678b0674b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -48,6 +48,7 @@ jobs:
48 ENABLE_OBJECT_STORAGE_TESTS: true 48 ENABLE_OBJECT_STORAGE_TESTS: true
49 OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }} 49 OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }}
50 OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }} 50 OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }}
51 YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51 52
52 steps: 53 steps:
53 - uses: actions/checkout@v3 54 - uses: actions/checkout@v3
diff --git a/client/.gitignore b/client/.gitignore
index 3241b09ed..ca68413c8 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -11,5 +11,6 @@
11/src/locale/target/server_*.xml 11/src/locale/target/server_*.xml
12/e2e/local.log 12/e2e/local.log
13/e2e/browserstack.err 13/e2e/browserstack.err
14/e2e/screenshots
14/src/standalone/player/build 15/src/standalone/player/build
15/src/standalone/player/dist 16/src/standalone/player/dist
diff --git a/client/e2e/src/po/admin-config.po.ts b/client/e2e/src/po/admin-config.po.ts
index 27957a71f..510037ddd 100644
--- a/client/e2e/src/po/admin-config.po.ts
+++ b/client/e2e/src/po/admin-config.po.ts
@@ -1,4 +1,4 @@
1import { getCheckbox, go } from '../utils' 1import { browserSleep, getCheckbox, go, isCheckboxSelected } from '../utils'
2 2
3export class AdminConfigPage { 3export class AdminConfigPage {
4 4
@@ -8,7 +8,6 @@ export class AdminConfigPage {
8 'basic-configuration': 'APPEARANCE', 8 'basic-configuration': 'APPEARANCE',
9 'instance-information': 'INSTANCE' 9 'instance-information': 'INSTANCE'
10 } 10 }
11
12 await go('/admin/config/edit-custom#' + tab) 11 await go('/admin/config/edit-custom#' + tab)
13 12
14 await $('.inner-form-title=' + waitTitles[tab]).waitForDisplayed() 13 await $('.inner-form-title=' + waitTitles[tab]).waitForDisplayed()
@@ -28,17 +27,39 @@ export class AdminConfigPage {
28 return $('#instanceCustomHomepageContent').setValue(newValue) 27 return $('#instanceCustomHomepageContent').setValue(newValue)
29 } 28 }
30 29
31 async toggleSignup () { 30 async toggleSignup (enabled: boolean) {
31 if (await isCheckboxSelected('signupEnabled') === enabled) return
32
32 const checkbox = await getCheckbox('signupEnabled') 33 const checkbox = await getCheckbox('signupEnabled')
33 34
34 await checkbox.waitForClickable() 35 await checkbox.waitForClickable()
35 await checkbox.click() 36 await checkbox.click()
36 } 37 }
37 38
39 async toggleSignupApproval (required: boolean) {
40 if (await isCheckboxSelected('signupRequiresApproval') === required) return
41
42 const checkbox = await getCheckbox('signupRequiresApproval')
43
44 await checkbox.waitForClickable()
45 await checkbox.click()
46 }
47
48 async toggleSignupEmailVerification (required: boolean) {
49 if (await isCheckboxSelected('signupRequiresEmailVerification') === required) return
50
51 const checkbox = await getCheckbox('signupRequiresEmailVerification')
52
53 await checkbox.waitForClickable()
54 await checkbox.click()
55 }
56
38 async save () { 57 async save () {
39 const button = $('input[type=submit]') 58 const button = $('input[type=submit]')
40 59
41 await button.waitForClickable() 60 await button.waitForClickable()
42 await button.click() 61 await button.click()
62
63 await browserSleep(1000)
43 } 64 }
44} 65}
diff --git a/client/e2e/src/po/admin-registration.po.ts b/client/e2e/src/po/admin-registration.po.ts
new file mode 100644
index 000000000..85234654d
--- /dev/null
+++ b/client/e2e/src/po/admin-registration.po.ts
@@ -0,0 +1,35 @@
1import { browserSleep, findParentElement, go } from '../utils'
2
3export class AdminRegistrationPage {
4
5 async navigateToRegistratonsList () {
6 await go('/admin/moderation/registrations/list')
7
8 await $('my-registration-list').waitForDisplayed()
9 }
10
11 async accept (username: string, moderationResponse: string) {
12 const usernameEl = await $('*=' + username)
13 await usernameEl.waitForDisplayed()
14
15 const tr = await findParentElement(usernameEl, async el => await el.getTagName() === 'tr')
16
17 await tr.$('.action-cell .dropdown-root').click()
18
19 const accept = await $('span*=Accept this registration')
20 await accept.waitForClickable()
21 await accept.click()
22
23 const moderationResponseTextarea = await $('#moderationResponse')
24 await moderationResponseTextarea.waitForDisplayed()
25
26 await moderationResponseTextarea.setValue(moderationResponse)
27
28 const submitButton = $('.modal-footer input[type=submit]')
29 await submitButton.waitForClickable()
30 await submitButton.click()
31
32 await browserSleep(1000)
33 }
34
35}
diff --git a/client/e2e/src/po/login.po.ts b/client/e2e/src/po/login.po.ts
index bc1854dbc..f1d13a2b0 100644
--- a/client/e2e/src/po/login.po.ts
+++ b/client/e2e/src/po/login.po.ts
@@ -6,7 +6,14 @@ export class LoginPage {
6 6
7 } 7 }
8 8
9 async login (username: string, password: string, url = '/login') { 9 async login (options: {
10 username: string
11 password: string
12 displayName?: string
13 url?: string
14 }) {
15 const { username, password, url = '/login', displayName = username } = options
16
10 await go(url) 17 await go(url)
11 18
12 await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`) 19 await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`)
@@ -27,27 +34,40 @@ export class LoginPage {
27 34
28 await menuToggle.click() 35 await menuToggle.click()
29 36
30 await this.ensureIsLoggedInAs(username) 37 await this.ensureIsLoggedInAs(displayName)
31 38
32 await menuToggle.click() 39 await menuToggle.click()
33 } else { 40 } else {
34 await this.ensureIsLoggedInAs(username) 41 await this.ensureIsLoggedInAs(displayName)
35 } 42 }
36 } 43 }
37 44
45 async getLoginError (username: string, password: string) {
46 await go('/login')
47
48 await $('input#username').setValue(username)
49 await $('input#password').setValue(password)
50
51 await browser.pause(1000)
52
53 await $('form input[type=submit]').click()
54
55 return $('.alert-danger').getText()
56 }
57
38 async loginAsRootUser () { 58 async loginAsRootUser () {
39 return this.login('root', 'test' + this.getSuffix()) 59 return this.login({ username: 'root', password: 'test' + this.getSuffix() })
40 } 60 }
41 61
42 loginOnPeerTube2 () { 62 loginOnPeerTube2 () {
43 return this.login('e2e', process.env.PEERTUBE2_E2E_PASSWORD, 'https://peertube2.cpy.re/login') 63 return this.login({ username: 'e2e', password: process.env.PEERTUBE2_E2E_PASSWORD, url: 'https://peertube2.cpy.re/login' })
44 } 64 }
45 65
46 async logout () { 66 async logout () {
47 const loggedInMore = $('.logged-in-more') 67 const loggedInDropdown = $('.logged-in-more .logged-in-info')
48 68
49 await loggedInMore.waitForClickable() 69 await loggedInDropdown.waitForClickable()
50 await loggedInMore.click() 70 await loggedInDropdown.click()
51 71
52 const logout = $('.dropdown-item*=Log out') 72 const logout = $('.dropdown-item*=Log out')
53 73
diff --git a/client/e2e/src/po/signup.po.ts b/client/e2e/src/po/signup.po.ts
index cc2ed7c5f..7917cdda7 100644
--- a/client/e2e/src/po/signup.po.ts
+++ b/client/e2e/src/po/signup.po.ts
@@ -27,42 +27,39 @@ export class SignupPage {
27 return terms.click() 27 return terms.click()
28 } 28 }
29 29
30 async getEndMessage () {
31 const alert = $('.pt-alert-primary')
32 await alert.waitForDisplayed()
33
34 return alert.getText()
35 }
36
37 async fillRegistrationReason (reason: string) {
38 await $('#registrationReason').setValue(reason)
39 }
40
30 async fillAccountStep (options: { 41 async fillAccountStep (options: {
31 displayName: string
32 username: string 42 username: string
33 email: string 43 password?: string
34 password: string 44 displayName?: string
45 email?: string
35 }) { 46 }) {
36 if (options.displayName) { 47 await $('#displayName').setValue(options.displayName || `${options.username} display name`)
37 await $('#displayName').setValue(options.displayName)
38 }
39
40 if (options.username) {
41 await $('#username').setValue(options.username)
42 }
43 48
44 if (options.email) { 49 await $('#username').setValue(options.username)
45 // Fix weird bug on firefox that "cannot scroll into view" when using just `setValue` 50 await $('#password').setValue(options.password || 'password')
46 await $('#email').scrollIntoView(false)
47 await $('#email').waitForClickable()
48 await $('#email').setValue(options.email)
49 }
50 51
51 if (options.password) { 52 // Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
52 await $('#password').setValue(options.password) 53 await $('#email').scrollIntoView(false)
53 } 54 await $('#email').waitForClickable()
55 await $('#email').setValue(options.email || `${options.username}@example.com`)
54 } 56 }
55 57
56 async fillChannelStep (options: { 58 async fillChannelStep (options: {
57 displayName: string
58 name: string 59 name: string
60 displayName?: string
59 }) { 61 }) {
60 if (options.displayName) { 62 await $('#displayName').setValue(options.displayName || `${options.name} channel display name`)
61 await $('#displayName').setValue(options.displayName) 63 await $('#name').setValue(options.name)
62 }
63
64 if (options.name) {
65 await $('#name').setValue(options.name)
66 }
67 } 64 }
68} 65}
diff --git a/client/e2e/src/suites-local/signup.e2e-spec.ts b/client/e2e/src/suites-local/signup.e2e-spec.ts
index 4eed3eefe..b6f7ad1a7 100644
--- a/client/e2e/src/suites-local/signup.e2e-spec.ts
+++ b/client/e2e/src/suites-local/signup.e2e-spec.ts
@@ -1,12 +1,89 @@
1import { AdminConfigPage } from '../po/admin-config.po' 1import { AdminConfigPage } from '../po/admin-config.po'
2import { AdminRegistrationPage } from '../po/admin-registration.po'
2import { LoginPage } from '../po/login.po' 3import { LoginPage } from '../po/login.po'
3import { SignupPage } from '../po/signup.po' 4import { SignupPage } from '../po/signup.po'
4import { isMobileDevice, waitServerUp } from '../utils' 5import { browserSleep, getVerificationLink, go, findEmailTo, isMobileDevice, MockSMTPServer, waitServerUp } from '../utils'
6
7function checkEndMessage (options: {
8 message: string
9 requiresEmailVerification: boolean
10 requiresApproval: boolean
11 afterEmailVerification: boolean
12}) {
13 const { message, requiresApproval, requiresEmailVerification, afterEmailVerification } = options
14
15 {
16 const created = 'account has been created'
17 const request = 'account request has been sent'
18
19 if (requiresApproval) {
20 expect(message).toContain(request)
21 expect(message).not.toContain(created)
22 } else {
23 expect(message).not.toContain(request)
24 expect(message).toContain(created)
25 }
26 }
27
28 {
29 const checkEmail = 'Check your emails'
30
31 if (requiresEmailVerification) {
32 expect(message).toContain(checkEmail)
33 } else {
34 expect(message).not.toContain(checkEmail)
35
36 const moderatorsApproval = 'moderator will check your registration request'
37 if (requiresApproval) {
38 expect(message).toContain(moderatorsApproval)
39 } else {
40 expect(message).not.toContain(moderatorsApproval)
41 }
42 }
43 }
44
45 {
46 const emailVerified = 'email has been verified'
47
48 if (afterEmailVerification) {
49 expect(message).toContain(emailVerified)
50 } else {
51 expect(message).not.toContain(emailVerified)
52 }
53 }
54}
5 55
6describe('Signup', () => { 56describe('Signup', () => {
7 let loginPage: LoginPage 57 let loginPage: LoginPage
8 let adminConfigPage: AdminConfigPage 58 let adminConfigPage: AdminConfigPage
9 let signupPage: SignupPage 59 let signupPage: SignupPage
60 let adminRegistrationPage: AdminRegistrationPage
61
62 async function prepareSignup (options: {
63 enabled: boolean
64 requiresApproval?: boolean
65 requiresEmailVerification?: boolean
66 }) {
67 await loginPage.loginAsRootUser()
68
69 await adminConfigPage.navigateTo('basic-configuration')
70 await adminConfigPage.toggleSignup(options.enabled)
71
72 if (options.enabled) {
73 if (options.requiresApproval !== undefined) {
74 await adminConfigPage.toggleSignupApproval(options.requiresApproval)
75 }
76
77 if (options.requiresEmailVerification !== undefined) {
78 await adminConfigPage.toggleSignupEmailVerification(options.requiresEmailVerification)
79 }
80 }
81
82 await adminConfigPage.save()
83
84 await loginPage.logout()
85 await browser.refresh()
86 }
10 87
11 before(async () => { 88 before(async () => {
12 await waitServerUp() 89 await waitServerUp()
@@ -16,72 +93,310 @@ describe('Signup', () => {
16 loginPage = new LoginPage(isMobileDevice()) 93 loginPage = new LoginPage(isMobileDevice())
17 adminConfigPage = new AdminConfigPage() 94 adminConfigPage = new AdminConfigPage()
18 signupPage = new SignupPage() 95 signupPage = new SignupPage()
96 adminRegistrationPage = new AdminRegistrationPage()
19 97
20 await browser.maximizeWindow() 98 await browser.maximizeWindow()
21 }) 99 })
22 100
23 it('Should disable signup', async () => { 101 describe('Signup disabled', function () {
24 await loginPage.loginAsRootUser() 102 it('Should disable signup', async () => {
103 await prepareSignup({ enabled: false })
25 104
26 await adminConfigPage.navigateTo('basic-configuration') 105 await expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
27 await adminConfigPage.toggleSignup() 106 })
107 })
28 108
29 await adminConfigPage.save() 109 describe('Email verification disabled', function () {
30 110
31 await loginPage.logout() 111 describe('Direct registration', function () {
32 await browser.refresh()
33 112
34 expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed() 113 it('Should enable signup without approval', async () => {
35 }) 114 await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: false })
36 115
37 it('Should enable signup', async () => { 116 await signupPage.getRegisterMenuButton().waitForDisplayed()
38 await loginPage.loginAsRootUser() 117 })
39 118
40 await adminConfigPage.navigateTo('basic-configuration') 119 it('Should go on signup page', async function () {
41 await adminConfigPage.toggleSignup() 120 await signupPage.clickOnRegisterInMenu()
121 })
42 122
43 await adminConfigPage.save() 123 it('Should validate the first step (about page)', async function () {
124 await signupPage.validateStep()
125 })
44 126
45 await loginPage.logout() 127 it('Should validate the second step (terms)', async function () {
46 await browser.refresh() 128 await signupPage.checkTerms()
129 await signupPage.validateStep()
130 })
47 131
48 expect(signupPage.getRegisterMenuButton()).toBeDisplayed() 132 it('Should validate the third step (account)', async function () {
49 }) 133 await signupPage.fillAccountStep({ username: 'user_1', displayName: 'user_1_dn' })
50 134
51 it('Should go on signup page', async function () { 135 await signupPage.validateStep()
52 await signupPage.clickOnRegisterInMenu() 136 })
53 })
54 137
55 it('Should validate the first step (about page)', async function () { 138 it('Should validate the third step (channel)', async function () {
56 await signupPage.validateStep() 139 await signupPage.fillChannelStep({ name: 'user_1_channel' })
57 })
58 140
59 it('Should validate the second step (terms)', async function () { 141 await signupPage.validateStep()
60 await signupPage.checkTerms() 142 })
61 await signupPage.validateStep() 143
62 }) 144 it('Should be logged in', async function () {
145 await loginPage.ensureIsLoggedInAs('user_1_dn')
146 })
147
148 it('Should have a valid end message', async function () {
149 const message = await signupPage.getEndMessage()
150
151 checkEndMessage({
152 message,
153 requiresEmailVerification: false,
154 requiresApproval: false,
155 afterEmailVerification: false
156 })
63 157
64 it('Should validate the third step (account)', async function () { 158 await browser.saveScreenshot('./screenshots/direct-without-email.png')
65 await signupPage.fillAccountStep({ 159
66 displayName: 'user 1', 160 await loginPage.logout()
67 username: 'user_1', 161 })
68 email: 'user_1@example.com',
69 password: 'my_super_password'
70 }) 162 })
71 163
72 await signupPage.validateStep() 164 describe('Registration with approval', function () {
165
166 it('Should enable signup with approval', async () => {
167 await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: false })
168
169 await signupPage.getRegisterMenuButton().waitForDisplayed()
170 })
171
172 it('Should go on signup page', async function () {
173 await signupPage.clickOnRegisterInMenu()
174 })
175
176 it('Should validate the first step (about page)', async function () {
177 await signupPage.validateStep()
178 })
179
180 it('Should validate the second step (terms)', async function () {
181 await signupPage.checkTerms()
182 await signupPage.fillRegistrationReason('my super reason')
183 await signupPage.validateStep()
184 })
185
186 it('Should validate the third step (account)', async function () {
187 await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'password' })
188 await signupPage.validateStep()
189 })
190
191 it('Should validate the third step (channel)', async function () {
192 await signupPage.fillChannelStep({ name: 'user_2_channel' })
193 await signupPage.validateStep()
194 })
195
196 it('Should have a valid end message', async function () {
197 const message = await signupPage.getEndMessage()
198
199 checkEndMessage({
200 message,
201 requiresEmailVerification: false,
202 requiresApproval: true,
203 afterEmailVerification: false
204 })
205
206 await browser.saveScreenshot('./screenshots/request-without-email.png')
207 })
208
209 it('Should display a message when trying to login with this account', async function () {
210 const error = await loginPage.getLoginError('user_2', 'password')
211
212 expect(error).toContain('awaiting approval')
213 })
214
215 it('Should accept the registration', async function () {
216 await loginPage.loginAsRootUser()
217
218 await adminRegistrationPage.navigateToRegistratonsList()
219 await adminRegistrationPage.accept('user_2', 'moderation response')
220
221 await loginPage.logout()
222 })
223
224 it('Should be able to login with this new account', async function () {
225 await loginPage.login({ username: 'user_2', password: 'password', displayName: 'user_2 display name' })
226
227 await loginPage.logout()
228 })
229 })
73 }) 230 })
74 231
75 it('Should validate the third step (channel)', async function () { 232 describe('Email verification enabled', function () {
76 await signupPage.fillChannelStep({ 233 const emails: any[] = []
77 displayName: 'user 1 channel', 234 let emailPort: number
78 name: 'user_1_channel' 235
236 before(async () => {
237 // FIXME: typings are wrong, get returns a promise
238 emailPort = await browser.sharedStore.get('emailPort') as unknown as number
239
240 MockSMTPServer.Instance.collectEmails(emailPort, emails)
79 }) 241 })
80 242
81 await signupPage.validateStep() 243 describe('Direct registration', function () {
82 }) 244
245 it('Should enable signup without approval', async () => {
246 await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: true })
247
248 await signupPage.getRegisterMenuButton().waitForDisplayed()
249 })
250
251 it('Should go on signup page', async function () {
252 await signupPage.clickOnRegisterInMenu()
253 })
254
255 it('Should validate the first step (about page)', async function () {
256 await signupPage.validateStep()
257 })
258
259 it('Should validate the second step (terms)', async function () {
260 await signupPage.checkTerms()
261 await signupPage.validateStep()
262 })
263
264 it('Should validate the third step (account)', async function () {
265 await signupPage.fillAccountStep({ username: 'user_3', displayName: 'user_3 display name', email: 'user_3@example.com' })
266
267 await signupPage.validateStep()
268 })
269
270 it('Should validate the third step (channel)', async function () {
271 await signupPage.fillChannelStep({ name: 'user_3_channel' })
272
273 await signupPage.validateStep()
274 })
275
276 it('Should have a valid end message', async function () {
277 const message = await signupPage.getEndMessage()
278
279 checkEndMessage({
280 message,
281 requiresEmailVerification: true,
282 requiresApproval: false,
283 afterEmailVerification: false
284 })
285
286 await browser.saveScreenshot('./screenshots/direct-with-email.png')
287 })
288
289 it('Should validate the email', async function () {
290 let email: { text: string }
291
292 while (!(email = findEmailTo(emails, 'user_3@example.com'))) {
293 await browserSleep(100)
294 }
295
296 await go(getVerificationLink(email))
297
298 const message = await signupPage.getEndMessage()
299
300 checkEndMessage({
301 message,
302 requiresEmailVerification: false,
303 requiresApproval: false,
304 afterEmailVerification: true
305 })
83 306
84 it('Should be logged in', async function () { 307 await browser.saveScreenshot('./screenshots/direct-after-email.png')
85 await loginPage.ensureIsLoggedInAs('user 1') 308 })
309 })
310
311 describe('Registration with approval', function () {
312
313 it('Should enable signup without approval', async () => {
314 await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: true })
315
316 await signupPage.getRegisterMenuButton().waitForDisplayed()
317 })
318
319 it('Should go on signup page', async function () {
320 await signupPage.clickOnRegisterInMenu()
321 })
322
323 it('Should validate the first step (about page)', async function () {
324 await signupPage.validateStep()
325 })
326
327 it('Should validate the second step (terms)', async function () {
328 await signupPage.checkTerms()
329 await signupPage.fillRegistrationReason('my super reason 2')
330 await signupPage.validateStep()
331 })
332
333 it('Should validate the third step (account)', async function () {
334 await signupPage.fillAccountStep({
335 username: 'user_4',
336 displayName: 'user_4 display name',
337 email: 'user_4@example.com',
338 password: 'password'
339 })
340 await signupPage.validateStep()
341 })
342
343 it('Should validate the third step (channel)', async function () {
344 await signupPage.fillChannelStep({ name: 'user_4_channel' })
345 await signupPage.validateStep()
346 })
347
348 it('Should have a valid end message', async function () {
349 const message = await signupPage.getEndMessage()
350
351 checkEndMessage({
352 message,
353 requiresEmailVerification: true,
354 requiresApproval: true,
355 afterEmailVerification: false
356 })
357
358 await browser.saveScreenshot('./screenshots/request-with-email.png')
359 })
360
361 it('Should display a message when trying to login with this account', async function () {
362 const error = await loginPage.getLoginError('user_4', 'password')
363
364 expect(error).toContain('awaiting approval')
365 })
366
367 it('Should accept the registration', async function () {
368 await loginPage.loginAsRootUser()
369
370 await adminRegistrationPage.navigateToRegistratonsList()
371 await adminRegistrationPage.accept('user_4', 'moderation response 2')
372
373 await loginPage.logout()
374 })
375
376 it('Should validate the email', async function () {
377 let email: { text: string }
378
379 while (!(email = findEmailTo(emails, 'user_4@example.com'))) {
380 await browserSleep(100)
381 }
382
383 await go(getVerificationLink(email))
384
385 const message = await signupPage.getEndMessage()
386
387 checkEndMessage({
388 message,
389 requiresEmailVerification: false,
390 requiresApproval: true,
391 afterEmailVerification: true
392 })
393
394 await browser.saveScreenshot('./screenshots/request-after-email.png')
395 })
396 })
397
398 before(() => {
399 MockSMTPServer.Instance.kill()
400 })
86 }) 401 })
87}) 402})
diff --git a/client/e2e/src/utils/elements.ts b/client/e2e/src/utils/elements.ts
index b0ddd5a65..d9435e520 100644
--- a/client/e2e/src/utils/elements.ts
+++ b/client/e2e/src/utils/elements.ts
@@ -5,6 +5,10 @@ async function getCheckbox (name: string) {
5 return input.parentElement() 5 return input.parentElement()
6} 6}
7 7
8function isCheckboxSelected (name: string) {
9 return $(`input[id=${name}]`).isSelected()
10}
11
8async function selectCustomSelect (id: string, valueLabel: string) { 12async function selectCustomSelect (id: string, valueLabel: string) {
9 const wrapper = $(`[formcontrolname=${id}] .ng-arrow-wrapper`) 13 const wrapper = $(`[formcontrolname=${id}] .ng-arrow-wrapper`)
10 14
@@ -22,7 +26,18 @@ async function selectCustomSelect (id: string, valueLabel: string) {
22 return option.click() 26 return option.click()
23} 27}
24 28
29async function findParentElement (
30 el: WebdriverIO.Element,
31 finder: (el: WebdriverIO.Element) => Promise<boolean>
32) {
33 if (await finder(el) === true) return el
34
35 return findParentElement(await el.parentElement(), finder)
36}
37
25export { 38export {
26 getCheckbox, 39 getCheckbox,
27 selectCustomSelect 40 isCheckboxSelected,
41 selectCustomSelect,
42 findParentElement
28} 43}
diff --git a/client/e2e/src/utils/email.ts b/client/e2e/src/utils/email.ts
new file mode 100644
index 000000000..2ad120333
--- /dev/null
+++ b/client/e2e/src/utils/email.ts
@@ -0,0 +1,31 @@
1function getVerificationLink (email: { text: string }) {
2 const { text } = email
3
4 const regexp = /\[(?<link>http:\/\/[^\]]+)\]/g
5 const matched = text.matchAll(regexp)
6
7 if (!matched) throw new Error('Could not find verification link in email')
8
9 for (const match of matched) {
10 const link = match.groups.link
11
12 if (link.includes('/verify-account/')) return link
13 }
14
15 throw new Error('Could not find /verify-account/ link')
16}
17
18function findEmailTo (emails: { text: string, to: { address: string }[] }[], to: string) {
19 for (const email of emails) {
20 for (const { address } of email.to) {
21 if (address === to) return email
22 }
23 }
24
25 return undefined
26}
27
28export {
29 getVerificationLink,
30 findEmailTo
31}
diff --git a/client/e2e/src/utils/hooks.ts b/client/e2e/src/utils/hooks.ts
index 889cf1d86..7fe247681 100644
--- a/client/e2e/src/utils/hooks.ts
+++ b/client/e2e/src/utils/hooks.ts
@@ -1,10 +1,13 @@
1import { ChildProcessWithoutNullStreams } from 'child_process' 1import { ChildProcessWithoutNullStreams } from 'child_process'
2import { basename } from 'path' 2import { basename } from 'path'
3import { runCommand, runServer } from './server' 3import { runCommand, runServer } from './server'
4import { setValue } from '@wdio/shared-store-service'
4 5
5let appInstance: string 6let appInstance: number
6let app: ChildProcessWithoutNullStreams 7let app: ChildProcessWithoutNullStreams
7 8
9let emailPort: number
10
8async function beforeLocalSuite (suite: any) { 11async function beforeLocalSuite (suite: any) {
9 const config = buildConfig(suite.file) 12 const config = buildConfig(suite.file)
10 13
@@ -17,13 +20,20 @@ function afterLocalSuite () {
17 app = undefined 20 app = undefined
18} 21}
19 22
20function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) { 23async function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
21 appInstance = capabilities['browserName'] === 'chrome' ? '1' : '2' 24 appInstance = capabilities['browserName'] === 'chrome'
25 ? 1
26 : 2
27
28 emailPort = 1025 + appInstance
29
22 config.baseUrl = 'http://localhost:900' + appInstance 30 config.baseUrl = 'http://localhost:900' + appInstance
31
32 await setValue('emailPort', emailPort)
23} 33}
24 34
25async function onBrowserStackPrepare () { 35async function onBrowserStackPrepare () {
26 const appInstance = '1' 36 const appInstance = 1
27 37
28 await runCommand('npm run clean:server:test -- ' + appInstance) 38 await runCommand('npm run clean:server:test -- ' + appInstance)
29 app = runServer(appInstance) 39 app = runServer(appInstance)
@@ -71,7 +81,11 @@ function buildConfig (suiteFile: string = undefined) {
71 if (filename === 'signup.e2e-spec.ts') { 81 if (filename === 'signup.e2e-spec.ts') {
72 return { 82 return {
73 signup: { 83 signup: {
74 enabled: true 84 limit: -1
85 },
86 smtp: {
87 hostname: '127.0.0.1',
88 port: emailPort
75 } 89 }
76 } 90 }
77 } 91 }
diff --git a/client/e2e/src/utils/index.ts b/client/e2e/src/utils/index.ts
index 354352ee2..420fd239e 100644
--- a/client/e2e/src/utils/index.ts
+++ b/client/e2e/src/utils/index.ts
@@ -1,5 +1,7 @@
1export * from './common' 1export * from './common'
2export * from './elements' 2export * from './elements'
3export * from './email'
3export * from './hooks' 4export * from './hooks'
5export * from './mock-smtp'
4export * from './server' 6export * from './server'
5export * from './urls' 7export * from './urls'
diff --git a/client/e2e/src/utils/mock-smtp.ts b/client/e2e/src/utils/mock-smtp.ts
new file mode 100644
index 000000000..614477d7d
--- /dev/null
+++ b/client/e2e/src/utils/mock-smtp.ts
@@ -0,0 +1,58 @@
1import { ChildProcess } from 'child_process'
2import MailDev from '@peertube/maildev'
3
4class MockSMTPServer {
5
6 private static instance: MockSMTPServer
7 private started = false
8 private emailChildProcess: ChildProcess
9 private emails: object[]
10
11 collectEmails (port: number, emailsCollection: object[]) {
12 return new Promise<number>((res, rej) => {
13 this.emails = emailsCollection
14
15 if (this.started) {
16 return res(undefined)
17 }
18
19 const maildev = new MailDev({
20 ip: '127.0.0.1',
21 smtp: port,
22 disableWeb: true,
23 silent: true
24 })
25
26 maildev.on('new', email => {
27 this.emails.push(email)
28 })
29
30 maildev.listen(err => {
31 if (err) return rej(err)
32
33 this.started = true
34
35 return res(port)
36 })
37 })
38 }
39
40 kill () {
41 if (!this.emailChildProcess) return
42
43 process.kill(this.emailChildProcess.pid)
44
45 this.emailChildProcess = null
46 MockSMTPServer.instance = null
47 }
48
49 static get Instance () {
50 return this.instance || (this.instance = new this())
51 }
52}
53
54// ---------------------------------------------------------------------------
55
56export {
57 MockSMTPServer
58}
diff --git a/client/e2e/src/utils/server.ts b/client/e2e/src/utils/server.ts
index 140054794..227f4aea6 100644
--- a/client/e2e/src/utils/server.ts
+++ b/client/e2e/src/utils/server.ts
@@ -1,10 +1,10 @@
1import { exec, spawn } from 'child_process' 1import { exec, spawn } from 'child_process'
2import { join, resolve } from 'path' 2import { join, resolve } from 'path'
3 3
4function runServer (appInstance: string, config: any = {}) { 4function runServer (appInstance: number, config: any = {}) {
5 const env = Object.create(process.env) 5 const env = Object.create(process.env)
6 env['NODE_ENV'] = 'test' 6 env['NODE_ENV'] = 'test'
7 env['NODE_APP_INSTANCE'] = appInstance 7 env['NODE_APP_INSTANCE'] = appInstance + ''
8 8
9 env['NODE_CONFIG'] = JSON.stringify({ 9 env['NODE_CONFIG'] = JSON.stringify({
10 rates_limit: { 10 rates_limit: {
diff --git a/client/e2e/wdio.local-test.conf.ts b/client/e2e/wdio.local-test.conf.ts
index ca0bb5bfe..bc15123a0 100644
--- a/client/e2e/wdio.local-test.conf.ts
+++ b/client/e2e/wdio.local-test.conf.ts
@@ -37,7 +37,7 @@ module.exports = {
37 // } 37 // }
38 ], 38 ],
39 39
40 services: [ 'chromedriver', 'geckodriver' ], 40 services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
41 41
42 beforeSession: beforeLocalSession, 42 beforeSession: beforeLocalSession,
43 beforeSuite: beforeLocalSuite, 43 beforeSuite: beforeLocalSuite,
diff --git a/client/e2e/wdio.local.conf.ts b/client/e2e/wdio.local.conf.ts
index d02679e06..27c6e867b 100644
--- a/client/e2e/wdio.local.conf.ts
+++ b/client/e2e/wdio.local.conf.ts
@@ -33,7 +33,7 @@ module.exports = {
33 } 33 }
34 ], 34 ],
35 35
36 services: [ 'chromedriver', 'geckodriver' ], 36 services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
37 37
38 beforeSession: beforeLocalSession, 38 beforeSession: beforeLocalSession,
39 beforeSuite: beforeLocalSuite, 39 beforeSuite: beforeLocalSuite,
diff --git a/client/package.json b/client/package.json
index 115a4a199..31d9b1e7c 100644
--- a/client/package.json
+++ b/client/package.json
@@ -52,8 +52,9 @@
52 "@ngx-loading-bar/core": "^6.0.0", 52 "@ngx-loading-bar/core": "^6.0.0",
53 "@ngx-loading-bar/http-client": "^6.0.0", 53 "@ngx-loading-bar/http-client": "^6.0.0",
54 "@ngx-loading-bar/router": "^6.0.0", 54 "@ngx-loading-bar/router": "^6.0.0",
55 "@peertube/p2p-media-loader-core": "^1.0.13", 55 "@peertube/maildev": "^1.2.0",
56 "@peertube/p2p-media-loader-hlsjs": "^1.0.13", 56 "@peertube/p2p-media-loader-core": "^1.0.14",
57 "@peertube/p2p-media-loader-hlsjs": "^1.0.14",
57 "@peertube/videojs-contextmenu": "^5.5.0", 58 "@peertube/videojs-contextmenu": "^5.5.0",
58 "@peertube/xliffmerge": "^2.0.3", 59 "@peertube/xliffmerge": "^2.0.3",
59 "@popperjs/core": "^2.11.5", 60 "@popperjs/core": "^2.11.5",
@@ -75,6 +76,7 @@
75 "@wdio/cli": "^7.25.2", 76 "@wdio/cli": "^7.25.2",
76 "@wdio/local-runner": "^7.25.2", 77 "@wdio/local-runner": "^7.25.2",
77 "@wdio/mocha-framework": "^7.25.2", 78 "@wdio/mocha-framework": "^7.25.2",
79 "@wdio/shared-store-service": "^7.25.2",
78 "@wdio/spec-reporter": "^7.25.1", 80 "@wdio/spec-reporter": "^7.25.1",
79 "angular2-hotkeys": "^13.1.0", 81 "angular2-hotkeys": "^13.1.0",
80 "angularx-qrcode": "14.0.0", 82 "angularx-qrcode": "14.0.0",
diff --git a/client/src/app/+about/about-instance/about-instance.component.html b/client/src/app/+about/about-instance/about-instance.component.html
index b113df82f..fdd6157e5 100644
--- a/client/src/app/+about/about-instance/about-instance.component.html
+++ b/client/src/app/+about/about-instance/about-instance.component.html
@@ -21,7 +21,7 @@
21 21
22 <div class="anchor" id="administrators-and-sustainability"></div> 22 <div class="anchor" id="administrators-and-sustainability"></div>
23 <a 23 <a
24 *ngIf="html.administrator || html.maintenanceLifetime || html.businessModel" 24 *ngIf="aboutHTML.administrator || aboutHTML.maintenanceLifetime || aboutHTML.businessModel"
25 class="anchor-link" 25 class="anchor-link"
26 routerLink="/about/instance" 26 routerLink="/about/instance"
27 fragment="administrators-and-sustainability" 27 fragment="administrators-and-sustainability"
@@ -33,7 +33,7 @@
33 </h2> 33 </h2>
34 </a> 34 </a>
35 35
36 <div class="block administrator" *ngIf="html.administrator"> 36 <div class="block administrator" *ngIf="aboutHTML.administrator">
37 <div class="anchor" id="administrators"></div> 37 <div class="anchor" id="administrators"></div>
38 <a 38 <a
39 class="anchor-link" 39 class="anchor-link"
@@ -44,10 +44,10 @@
44 <h3 i18n class="section-title">Who we are</h3> 44 <h3 i18n class="section-title">Who we are</h3>
45 </a> 45 </a>
46 46
47 <div [innerHTML]="html.administrator"></div> 47 <div [innerHTML]="aboutHTML.administrator"></div>
48 </div> 48 </div>
49 49
50 <div class="block creation-reason" *ngIf="html.creationReason"> 50 <div class="block creation-reason" *ngIf="aboutHTML.creationReason">
51 <div class="anchor" id="creation-reason"></div> 51 <div class="anchor" id="creation-reason"></div>
52 <a 52 <a
53 class="anchor-link" 53 class="anchor-link"
@@ -58,10 +58,10 @@
58 <h3 i18n class="section-title">Why we created this instance</h3> 58 <h3 i18n class="section-title">Why we created this instance</h3>
59 </a> 59 </a>
60 60
61 <div [innerHTML]="html.creationReason"></div> 61 <div [innerHTML]="aboutHTML.creationReason"></div>
62 </div> 62 </div>
63 63
64 <div class="block maintenance-lifetime" *ngIf="html.maintenanceLifetime"> 64 <div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
65 <div class="anchor" id="maintenance-lifetime"></div> 65 <div class="anchor" id="maintenance-lifetime"></div>
66 <a 66 <a
67 class="anchor-link" 67 class="anchor-link"
@@ -72,10 +72,10 @@
72 <h3 i18n class="section-title">How long we plan to maintain this instance</h3> 72 <h3 i18n class="section-title">How long we plan to maintain this instance</h3>
73 </a> 73 </a>
74 74
75 <div [innerHTML]="html.maintenanceLifetime"></div> 75 <div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
76 </div> 76 </div>
77 77
78 <div class="block business-model" *ngIf="html.businessModel"> 78 <div class="block business-model" *ngIf="aboutHTML.businessModel">
79 <div class="anchor" id="business-model"></div> 79 <div class="anchor" id="business-model"></div>
80 <a 80 <a
81 class="anchor-link" 81 class="anchor-link"
@@ -86,12 +86,12 @@
86 <h3 i18n class="section-title">How we will pay for keeping our instance running</h3> 86 <h3 i18n class="section-title">How we will pay for keeping our instance running</h3>
87 </a> 87 </a>
88 88
89 <div [innerHTML]="html.businessModel"></div> 89 <div [innerHTML]="aboutHTML.businessModel"></div>
90 </div> 90 </div>
91 91
92 <div class="anchor" id="information"></div> 92 <div class="anchor" id="information"></div>
93 <a 93 <a
94 *ngIf="descriptionContent" 94 *ngIf="descriptionElement"
95 class="anchor-link" 95 class="anchor-link"
96 routerLink="/about/instance" 96 routerLink="/about/instance"
97 fragment="information" 97 fragment="information"
@@ -113,13 +113,13 @@
113 <h3 i18n class="section-title">Description</h3> 113 <h3 i18n class="section-title">Description</h3>
114 </a> 114 </a>
115 115
116 <my-custom-markup-container [content]="descriptionContent"></my-custom-markup-container> 116 <my-custom-markup-container [content]="descriptionElement"></my-custom-markup-container>
117 </div> 117 </div>
118 118
119 <div myPluginSelector pluginSelectorId="about-instance-moderation"> 119 <div myPluginSelector pluginSelectorId="about-instance-moderation">
120 <div class="anchor" id="moderation"></div> 120 <div class="anchor" id="moderation"></div>
121 <a 121 <a
122 *ngIf="html.moderationInformation || html.codeOfConduct || html.terms" 122 *ngIf="aboutHTML.moderationInformation || aboutHTML.codeOfConduct || aboutHTML.terms"
123 class="anchor-link" 123 class="anchor-link"
124 routerLink="/about/instance" 124 routerLink="/about/instance"
125 fragment="moderation" 125 fragment="moderation"
@@ -130,7 +130,7 @@
130 </h2> 130 </h2>
131 </a> 131 </a>
132 132
133 <div class="block moderation-information" *ngIf="html.moderationInformation"> 133 <div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
134 <div class="anchor" id="moderation-information"></div> 134 <div class="anchor" id="moderation-information"></div>
135 <a 135 <a
136 class="anchor-link" 136 class="anchor-link"
@@ -141,10 +141,10 @@
141 <h3 i18n class="section-title">Moderation information</h3> 141 <h3 i18n class="section-title">Moderation information</h3>
142 </a> 142 </a>
143 143
144 <div [innerHTML]="html.moderationInformation"></div> 144 <div [innerHTML]="aboutHTML.moderationInformation"></div>
145 </div> 145 </div>
146 146
147 <div class="block code-of-conduct" *ngIf="html.codeOfConduct"> 147 <div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
148 <div class="anchor" id="code-of-conduct"></div> 148 <div class="anchor" id="code-of-conduct"></div>
149 <a 149 <a
150 class="anchor-link" 150 class="anchor-link"
@@ -155,7 +155,7 @@
155 <h3 i18n class="section-title">Code of conduct</h3> 155 <h3 i18n class="section-title">Code of conduct</h3>
156 </a> 156 </a>
157 157
158 <div [innerHTML]="html.codeOfConduct"></div> 158 <div [innerHTML]="aboutHTML.codeOfConduct"></div>
159 </div> 159 </div>
160 160
161 <div class="block terms"> 161 <div class="block terms">
@@ -169,14 +169,14 @@
169 <h3 i18n class="section-title">Terms</h3> 169 <h3 i18n class="section-title">Terms</h3>
170 </a> 170 </a>
171 171
172 <div [innerHTML]="html.terms"></div> 172 <div [innerHTML]="aboutHTML.terms"></div>
173 </div> 173 </div>
174 </div> 174 </div>
175 175
176 <div myPluginSelector pluginSelectorId="about-instance-other-information"> 176 <div myPluginSelector pluginSelectorId="about-instance-other-information">
177 <div class="anchor" id="other-information"></div> 177 <div class="anchor" id="other-information"></div>
178 <a 178 <a
179 *ngIf="html.hardwareInformation" 179 *ngIf="aboutHTML.hardwareInformation"
180 class="anchor-link" 180 class="anchor-link"
181 routerLink="/about/instance" 181 routerLink="/about/instance"
182 fragment="other-information" 182 fragment="other-information"
@@ -187,7 +187,7 @@
187 </h2> 187 </h2>
188 </a> 188 </a>
189 189
190 <div class="block hardware-information" *ngIf="html.hardwareInformation"> 190 <div class="block hardware-information" *ngIf="aboutHTML.hardwareInformation">
191 <div class="anchor" id="hardware-information"></div> 191 <div class="anchor" id="hardware-information"></div>
192 <a 192 <a
193 class="anchor-link" 193 class="anchor-link"
@@ -198,7 +198,7 @@
198 <h3 i18n class="section-title">Hardware information</h3> 198 <h3 i18n class="section-title">Hardware information</h3>
199 </a> 199 </a>
200 200
201 <div [innerHTML]="html.hardwareInformation"></div> 201 <div [innerHTML]="aboutHTML.hardwareInformation"></div>
202 </div> 202 </div>
203 </div> 203 </div>
204 </div> 204 </div>
diff --git a/client/src/app/+about/about-instance/about-instance.component.ts b/client/src/app/+about/about-instance/about-instance.component.ts
index 0826bbc5a..e1501d7de 100644
--- a/client/src/app/+about/about-instance/about-instance.component.ts
+++ b/client/src/app/+about/about-instance/about-instance.component.ts
@@ -2,7 +2,7 @@ import { ViewportScroller } from '@angular/common'
2import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core' 2import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute } from '@angular/router' 3import { ActivatedRoute } from '@angular/router'
4import { Notifier, ServerService } from '@app/core' 4import { Notifier, ServerService } from '@app/core'
5import { InstanceService } from '@app/shared/shared-instance' 5import { AboutHTML } from '@app/shared/shared-instance'
6import { copyToClipboard } from '@root-helpers/utils' 6import { copyToClipboard } from '@root-helpers/utils'
7import { HTMLServerConfig } from '@shared/models/server' 7import { HTMLServerConfig } from '@shared/models/server'
8import { ResolverData } from './about-instance.resolver' 8import { ResolverData } from './about-instance.resolver'
@@ -17,22 +17,12 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
17 @ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement> 17 @ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement>
18 @ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent 18 @ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
19 19
20 shortDescription = '' 20 aboutHTML: AboutHTML
21 descriptionContent: string 21 descriptionElement: HTMLDivElement
22
23 html = {
24 terms: '',
25 codeOfConduct: '',
26 moderationInformation: '',
27 administrator: '',
28 creationReason: '',
29 maintenanceLifetime: '',
30 businessModel: '',
31 hardwareInformation: ''
32 }
33 22
34 languages: string[] = [] 23 languages: string[] = []
35 categories: string[] = [] 24 categories: string[] = []
25 shortDescription = ''
36 26
37 initialized = false 27 initialized = false
38 28
@@ -44,8 +34,7 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
44 private viewportScroller: ViewportScroller, 34 private viewportScroller: ViewportScroller,
45 private route: ActivatedRoute, 35 private route: ActivatedRoute,
46 private notifier: Notifier, 36 private notifier: Notifier,
47 private serverService: ServerService, 37 private serverService: ServerService
48 private instanceService: InstanceService
49 ) {} 38 ) {}
50 39
51 get instanceName () { 40 get instanceName () {
@@ -60,8 +49,16 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
60 return this.serverConfig.instance.isNSFW 49 return this.serverConfig.instance.isNSFW
61 } 50 }
62 51
63 async ngOnInit () { 52 ngOnInit () {
64 const { about, languages, categories }: ResolverData = this.route.snapshot.data.instanceData 53 const { about, languages, categories, aboutHTML, descriptionElement }: ResolverData = this.route.snapshot.data.instanceData
54
55 this.aboutHTML = aboutHTML
56 this.descriptionElement = descriptionElement
57
58 this.languages = languages
59 this.categories = categories
60
61 this.shortDescription = about.instance.shortDescription
65 62
66 this.serverConfig = this.serverService.getHTMLConfig() 63 this.serverConfig = this.serverService.getHTMLConfig()
67 64
@@ -73,14 +70,6 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
73 this.contactAdminModal.show(prefill) 70 this.contactAdminModal.show(prefill)
74 }) 71 })
75 72
76 this.languages = languages
77 this.categories = categories
78
79 this.shortDescription = about.instance.shortDescription
80 this.descriptionContent = about.instance.description
81
82 this.html = await this.instanceService.buildHtml(about)
83
84 this.initialized = true 73 this.initialized = true
85 } 74 }
86 75
diff --git a/client/src/app/+about/about-instance/about-instance.resolver.ts b/client/src/app/+about/about-instance/about-instance.resolver.ts
index ee0219df0..8818fc582 100644
--- a/client/src/app/+about/about-instance/about-instance.resolver.ts
+++ b/client/src/app/+about/about-instance/about-instance.resolver.ts
@@ -2,16 +2,25 @@ import { forkJoin } from 'rxjs'
2import { map, switchMap } from 'rxjs/operators' 2import { map, switchMap } from 'rxjs/operators'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { Resolve } from '@angular/router' 4import { Resolve } from '@angular/router'
5import { InstanceService } from '@app/shared/shared-instance' 5import { CustomMarkupService } from '@app/shared/shared-custom-markup'
6import { AboutHTML, InstanceService } from '@app/shared/shared-instance'
6import { About } from '@shared/models/server' 7import { About } from '@shared/models/server'
7 8
8export type ResolverData = { about: About, languages: string[], categories: string[] } 9export type ResolverData = {
10 about: About
11 languages: string[]
12 categories: string[]
13 aboutHTML: AboutHTML
14 descriptionElement: HTMLDivElement
15}
9 16
10@Injectable() 17@Injectable()
11export class AboutInstanceResolver implements Resolve<any> { 18export class AboutInstanceResolver implements Resolve<any> {
12 19
13 constructor ( 20 constructor (
14 private instanceService: InstanceService 21 private instanceService: InstanceService,
22 private customMarkupService: CustomMarkupService
23
15 ) {} 24 ) {}
16 25
17 resolve () { 26 resolve () {
@@ -19,9 +28,15 @@ export class AboutInstanceResolver implements Resolve<any> {
19 .pipe( 28 .pipe(
20 switchMap(about => { 29 switchMap(about => {
21 return forkJoin([ 30 return forkJoin([
31 Promise.resolve(about),
22 this.instanceService.buildTranslatedLanguages(about), 32 this.instanceService.buildTranslatedLanguages(about),
23 this.instanceService.buildTranslatedCategories(about) 33 this.instanceService.buildTranslatedCategories(about),
24 ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories }) as ResolverData)) 34 this.instanceService.buildHtml(about),
35 this.customMarkupService.buildElement(about.instance.description)
36 ])
37 }),
38 map(([ about, languages, categories, aboutHTML, { rootElement } ]) => {
39 return { about, languages, categories, aboutHTML, descriptionElement: rootElement } as ResolverData
25 }) 40 })
26 ) 41 )
27 } 42 }
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index 746549555..630bfe253 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -96,6 +96,14 @@ export class AdminComponent implements OnInit {
96 children: [] 96 children: []
97 } 97 }
98 98
99 if (this.hasRegistrationsRight()) {
100 moderationItems.children.push({
101 label: $localize`Registrations`,
102 routerLink: '/admin/moderation/registrations/list',
103 iconName: 'user'
104 })
105 }
106
99 if (this.hasAbusesRight()) { 107 if (this.hasAbusesRight()) {
100 moderationItems.children.push({ 108 moderationItems.children.push({
101 label: $localize`Reports`, 109 label: $localize`Reports`,
@@ -229,4 +237,8 @@ export class AdminComponent implements OnInit {
229 private hasVideosRight () { 237 private hasVideosRight () {
230 return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS) 238 return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS)
231 } 239 }
240
241 private hasRegistrationsRight () {
242 return this.auth.getUser().hasRight(UserRight.MANAGE_REGISTRATIONS)
243 }
232} 244}
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index f01967ea6..891ff4ed1 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -30,7 +30,13 @@ import { FollowersListComponent, FollowModalComponent, VideoRedundanciesListComp
30import { FollowingListComponent } from './follows/following-list/following-list.component' 30import { FollowingListComponent } from './follows/following-list/following-list.component'
31import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component' 31import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
32import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component' 32import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
33import { AbuseListComponent, VideoBlockListComponent } from './moderation' 33import {
34 AbuseListComponent,
35 AdminRegistrationService,
36 ProcessRegistrationModalComponent,
37 RegistrationListComponent,
38 VideoBlockListComponent
39} from './moderation'
34import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist' 40import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
35import { 41import {
36 UserCreateComponent, 42 UserCreateComponent,
@@ -116,7 +122,10 @@ import { JobsComponent } from './system/jobs/jobs.component'
116 EditLiveConfigurationComponent, 122 EditLiveConfigurationComponent,
117 EditAdvancedConfigurationComponent, 123 EditAdvancedConfigurationComponent,
118 EditInstanceInformationComponent, 124 EditInstanceInformationComponent,
119 EditHomepageComponent 125 EditHomepageComponent,
126
127 RegistrationListComponent,
128 ProcessRegistrationModalComponent
120 ], 129 ],
121 130
122 exports: [ 131 exports: [
@@ -130,7 +139,8 @@ import { JobsComponent } from './system/jobs/jobs.component'
130 ConfigService, 139 ConfigService,
131 PluginApiService, 140 PluginApiService,
132 EditConfigurationService, 141 EditConfigurationService,
133 VideoAdminService 142 VideoAdminService,
143 AdminRegistrationService
134 ] 144 ]
135}) 145})
136export class AdminModule { } 146export class AdminModule { }
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
index 43f1438e0..0f3803f97 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
@@ -44,9 +44,13 @@
44 44
45 <div class="peertube-select-container"> 45 <div class="peertube-select-container">
46 <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control"> 46 <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
47 <option i18n value="publishedAt">Recently added videos</option>
48 <option i18n value="originallyPublishedAt">Original publication date</option>
49 <option i18n value="name">Name</option>
47 <option i18n value="hot">Hot videos</option> 50 <option i18n value="hot">Hot videos</option>
48 <option i18n value="most-viewed">Most viewed videos</option> 51 <option i18n value="most-viewed">Recent views</option>
49 <option i18n value="most-liked">Most liked videos</option> 52 <option i18n value="most-liked">Most liked videos</option>
53 <option i18n value="views">Global views</option>
50 </select> 54 </select>
51 </div> 55 </div>
52 56
@@ -167,12 +171,21 @@
167 </ng-container> 171 </ng-container>
168 172
169 <ng-container ngProjectAs="extra"> 173 <ng-container ngProjectAs="extra">
170 <my-peertube-checkbox [ngClass]="getDisabledSignupClass()" 174 <div class="form-group">
171 inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification" 175 <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
172 i18n-labelText labelText="Signup requires email verification" 176 inputName="signupRequiresApproval" formControlName="requiresApproval"
173 ></my-peertube-checkbox> 177 i18n-labelText labelText="Signup requires approval by moderators"
178 ></my-peertube-checkbox>
179 </div>
174 180
175 <div [ngClass]="getDisabledSignupClass()" class="mt-3"> 181 <div class="form-group">
182 <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
183 inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
184 i18n-labelText labelText="Signup requires email verification"
185 ></my-peertube-checkbox>
186 </div>
187
188 <div [ngClass]="getDisabledSignupClass()">
176 <label i18n for="signupLimit">Signup limit</label> 189 <label i18n for="signupLimit">Signup limit</label>
177 190
178 <div class="number-with-unit"> 191 <div class="number-with-unit">
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 168f4702c..2afe80a03 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -132,6 +132,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
132 signup: { 132 signup: {
133 enabled: null, 133 enabled: null,
134 limit: SIGNUP_LIMIT_VALIDATOR, 134 limit: SIGNUP_LIMIT_VALIDATOR,
135 requiresApproval: null,
135 requiresEmailVerification: null, 136 requiresEmailVerification: null,
136 minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR 137 minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
137 }, 138 },
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html
index 5339240bb..3d8414f5c 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html
@@ -17,7 +17,7 @@
17 17
18 <my-markdown-textarea 18 <my-markdown-textarea
19 name="instanceCustomHomepageContent" formControlName="content" 19 name="instanceCustomHomepageContent" formControlName="content"
20 [customMarkdownRenderer]="getCustomMarkdownRenderer()" 20 [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
21 [formError]="formErrors['instanceCustomHomepage.content']" 21 [formError]="formErrors['instanceCustomHomepage.content']"
22 ></my-markdown-textarea> 22 ></my-markdown-textarea>
23 23
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html
index b54733327..504afa189 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html
@@ -38,7 +38,7 @@
38 38
39 <my-markdown-textarea 39 <my-markdown-textarea
40 name="instanceDescription" formControlName="description" 40 name="instanceDescription" formControlName="description"
41 [customMarkdownRenderer]="getCustomMarkdownRenderer()" 41 [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
42 [formError]="formErrors['instance.description']" 42 [formError]="formErrors['instance.description']"
43 ></my-markdown-textarea> 43 ></my-markdown-textarea>
44 </div> 44 </div>
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.html b/client/src/app/+admin/follows/followers-list/followers-list.component.html
index 8fe0d2348..14c62f1af 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.html
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.html
@@ -9,14 +9,14 @@
9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" 9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers"
12 [(selection)]="selectedFollows" 12 [(selection)]="selectedRows"
13> 13>
14 <ng-template pTemplate="caption"> 14 <ng-template pTemplate="caption">
15 <div class="caption"> 15 <div class="caption">
16 <div class="left-buttons"> 16 <div class="left-buttons">
17 <my-action-dropdown 17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkFollowsActions" [entry]="selectedFollows" 19 [actions]="bulkActions" [entry]="selectedRows"
20 > 20 >
21 </my-action-dropdown> 21 </my-action-dropdown>
22 </div> 22 </div>
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
index b2d333e83..cebb2e1a2 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
@@ -12,7 +12,7 @@ import { ActorFollow } from '@shared/models'
12 templateUrl: './followers-list.component.html', 12 templateUrl: './followers-list.component.html',
13 styleUrls: [ './followers-list.component.scss' ] 13 styleUrls: [ './followers-list.component.scss' ]
14}) 14})
15export class FollowersListComponent extends RestTable implements OnInit { 15export class FollowersListComponent extends RestTable <ActorFollow> implements OnInit {
16 followers: ActorFollow[] = [] 16 followers: ActorFollow[] = []
17 totalRecords = 0 17 totalRecords = 0
18 sort: SortMeta = { field: 'createdAt', order: -1 } 18 sort: SortMeta = { field: 'createdAt', order: -1 }
@@ -20,8 +20,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
20 20
21 searchFilters: AdvancedInputFilter[] = [] 21 searchFilters: AdvancedInputFilter[] = []
22 22
23 selectedFollows: ActorFollow[] = [] 23 bulkActions: DropdownAction<ActorFollow[]>[] = []
24 bulkFollowsActions: DropdownAction<ActorFollow[]>[] = []
25 24
26 constructor ( 25 constructor (
27 private confirmService: ConfirmService, 26 private confirmService: ConfirmService,
@@ -36,7 +35,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
36 35
37 this.searchFilters = this.followService.buildFollowsListFilters() 36 this.searchFilters = this.followService.buildFollowsListFilters()
38 37
39 this.bulkFollowsActions = [ 38 this.bulkActions = [
40 { 39 {
41 label: $localize`Reject`, 40 label: $localize`Reject`,
42 handler: follows => this.rejectFollower(follows), 41 handler: follows => this.rejectFollower(follows),
@@ -105,12 +104,14 @@ export class FollowersListComponent extends RestTable implements OnInit {
105 } 104 }
106 105
107 async deleteFollowers (follows: ActorFollow[]) { 106 async deleteFollowers (follows: ActorFollow[]) {
107 const icuParams = { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
108
108 let message = $localize`Deleted followers will be able to send again a follow request.` 109 let message = $localize`Deleted followers will be able to send again a follow request.`
109 message += '<br /><br />' 110 message += '<br /><br />'
110 111
111 // eslint-disable-next-line max-len 112 // eslint-disable-next-line max-len
112 message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( 113 message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)(
113 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 114 icuParams,
114 $localize`Do you really want to delete these follow requests?` 115 $localize`Do you really want to delete these follow requests?`
115 ) 116 )
116 117
@@ -122,7 +123,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
122 next: () => { 123 next: () => {
123 // eslint-disable-next-line max-len 124 // eslint-disable-next-line max-len
124 const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( 125 const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)(
125 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 126 icuParams,
126 $localize`Follow requests removed` 127 $localize`Follow requests removed`
127 ) 128 )
128 129
@@ -139,11 +140,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
139 return follow.follower.name + '@' + follow.follower.host 140 return follow.follower.name + '@' + follow.follower.host
140 } 141 }
141 142
142 isInSelectionMode () { 143 protected reloadDataInternal () {
143 return this.selectedFollows.length !== 0
144 }
145
146 protected reloadData () {
147 this.followService.getFollowers({ pagination: this.pagination, sort: this.sort, search: this.search }) 144 this.followService.getFollowers({ pagination: this.pagination, sort: this.sort, search: this.search })
148 .subscribe({ 145 .subscribe({
149 next: resultList => { 146 next: resultList => {
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html
index f7abb7ede..eca79be71 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.html
+++ b/client/src/app/+admin/follows/following-list/following-list.component.html
@@ -9,14 +9,14 @@
9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" 9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts"
12 [(selection)]="selectedFollows" 12 [(selection)]="selectedRows"
13> 13>
14 <ng-template pTemplate="caption"> 14 <ng-template pTemplate="caption">
15 <div class="caption"> 15 <div class="caption">
16 <div class="left-buttons"> 16 <div class="left-buttons">
17 <my-action-dropdown 17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkFollowsActions" [entry]="selectedFollows" 19 [actions]="bulkActions" [entry]="selectedRows"
20 > 20 >
21 </my-action-dropdown> 21 </my-action-dropdown>
22 22
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts
index e3a56651a..71f2fbe66 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.ts
+++ b/client/src/app/+admin/follows/following-list/following-list.component.ts
@@ -12,7 +12,7 @@ import { prepareIcu } from '@app/helpers'
12 templateUrl: './following-list.component.html', 12 templateUrl: './following-list.component.html',
13 styleUrls: [ './following-list.component.scss' ] 13 styleUrls: [ './following-list.component.scss' ]
14}) 14})
15export class FollowingListComponent extends RestTable implements OnInit { 15export class FollowingListComponent extends RestTable <ActorFollow> implements OnInit {
16 @ViewChild('followModal') followModal: FollowModalComponent 16 @ViewChild('followModal') followModal: FollowModalComponent
17 17
18 following: ActorFollow[] = [] 18 following: ActorFollow[] = []
@@ -22,8 +22,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
22 22
23 searchFilters: AdvancedInputFilter[] = [] 23 searchFilters: AdvancedInputFilter[] = []
24 24
25 selectedFollows: ActorFollow[] = [] 25 bulkActions: DropdownAction<ActorFollow[]>[] = []
26 bulkFollowsActions: DropdownAction<ActorFollow[]>[] = []
27 26
28 constructor ( 27 constructor (
29 private notifier: Notifier, 28 private notifier: Notifier,
@@ -38,7 +37,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
38 37
39 this.searchFilters = this.followService.buildFollowsListFilters() 38 this.searchFilters = this.followService.buildFollowsListFilters()
40 39
41 this.bulkFollowsActions = [ 40 this.bulkActions = [
42 { 41 {
43 label: $localize`Delete`, 42 label: $localize`Delete`,
44 handler: follows => this.removeFollowing(follows) 43 handler: follows => this.removeFollowing(follows)
@@ -58,17 +57,15 @@ export class FollowingListComponent extends RestTable implements OnInit {
58 return follow.following.name === 'peertube' 57 return follow.following.name === 'peertube'
59 } 58 }
60 59
61 isInSelectionMode () {
62 return this.selectedFollows.length !== 0
63 }
64
65 buildFollowingName (follow: ActorFollow) { 60 buildFollowingName (follow: ActorFollow) {
66 return follow.following.name + '@' + follow.following.host 61 return follow.following.name + '@' + follow.following.host
67 } 62 }
68 63
69 async removeFollowing (follows: ActorFollow[]) { 64 async removeFollowing (follows: ActorFollow[]) {
65 const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) }
66
70 const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)( 67 const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)(
71 { count: follows.length, entryName: this.buildFollowingName(follows[0]) }, 68 icuParams,
72 $localize`Do you really want to unfollow these entries?` 69 $localize`Do you really want to unfollow these entries?`
73 ) 70 )
74 71
@@ -80,7 +77,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
80 next: () => { 77 next: () => {
81 // eslint-disable-next-line max-len 78 // eslint-disable-next-line max-len
82 const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)( 79 const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)(
83 { count: follows.length, entryName: this.buildFollowingName(follows[0]) }, 80 icuParams,
84 $localize`You are not following them anymore.` 81 $localize`You are not following them anymore.`
85 ) 82 )
86 83
@@ -92,7 +89,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
92 }) 89 })
93 } 90 }
94 91
95 protected reloadData () { 92 protected reloadDataInternal () {
96 this.followService.getFollowing({ pagination: this.pagination, sort: this.sort, search: this.search }) 93 this.followService.getFollowing({ pagination: this.pagination, sort: this.sort, search: this.search })
97 .subscribe({ 94 .subscribe({
98 next: resultList => { 95 next: resultList => {
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
index a89603048..b31c5b35e 100644
--- a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
@@ -162,7 +162,7 @@ export class VideoRedundanciesListComponent extends RestTable implements OnInit
162 162
163 } 163 }
164 164
165 protected reloadData () { 165 protected reloadDataInternal () {
166 const options = { 166 const options = {
167 pagination: this.pagination, 167 pagination: this.pagination,
168 sort: this.sort, 168 sort: this.sort,
diff --git a/client/src/app/+admin/moderation/index.ts b/client/src/app/+admin/moderation/index.ts
index 9dab270cc..135b4b408 100644
--- a/client/src/app/+admin/moderation/index.ts
+++ b/client/src/app/+admin/moderation/index.ts
@@ -1,4 +1,5 @@
1export * from './abuse-list' 1export * from './abuse-list'
2export * from './instance-blocklist' 2export * from './instance-blocklist'
3export * from './video-block-list' 3export * from './video-block-list'
4export * from './registration-list'
4export * from './moderation.routes' 5export * from './moderation.routes'
diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts
index 1ad301039..378d2bed7 100644
--- a/client/src/app/+admin/moderation/moderation.routes.ts
+++ b/client/src/app/+admin/moderation/moderation.routes.ts
@@ -4,6 +4,7 @@ import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } f
4import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list' 4import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
5import { UserRightGuard } from '@app/core' 5import { UserRightGuard } from '@app/core'
6import { UserRight } from '@shared/models' 6import { UserRight } from '@shared/models'
7import { RegistrationListComponent } from './registration-list'
7 8
8export const ModerationRoutes: Routes = [ 9export const ModerationRoutes: Routes = [
9 { 10 {
@@ -68,7 +69,19 @@ export const ModerationRoutes: Routes = [
68 } 69 }
69 }, 70 },
70 71
71 // We move this component in admin overview pages 72 {
73 path: 'registrations/list',
74 component: RegistrationListComponent,
75 canActivate: [ UserRightGuard ],
76 data: {
77 userRight: UserRight.MANAGE_REGISTRATIONS,
78 meta: {
79 title: $localize`User registrations`
80 }
81 }
82 },
83
84 // We moved this component in admin overview pages
72 { 85 {
73 path: 'video-comments', 86 path: 'video-comments',
74 redirectTo: 'video-comments/list', 87 redirectTo: 'video-comments/list',
diff --git a/client/src/app/+admin/moderation/registration-list/admin-registration.service.ts b/client/src/app/+admin/moderation/registration-list/admin-registration.service.ts
new file mode 100644
index 000000000..a9f13cf2f
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/admin-registration.service.ts
@@ -0,0 +1,81 @@
1import { SortMeta } from 'primeng/api'
2import { from } from 'rxjs'
3import { catchError, concatMap, toArray } from 'rxjs/operators'
4import { HttpClient, HttpParams } from '@angular/common/http'
5import { Injectable } from '@angular/core'
6import { RestExtractor, RestPagination, RestService } from '@app/core'
7import { arrayify } from '@shared/core-utils'
8import { ResultList, UserRegistration, UserRegistrationUpdateState } from '@shared/models'
9import { environment } from '../../../../environments/environment'
10
11@Injectable()
12export class AdminRegistrationService {
13 private static BASE_REGISTRATION_URL = environment.apiUrl + '/api/v1/users/registrations'
14
15 constructor (
16 private authHttp: HttpClient,
17 private restExtractor: RestExtractor,
18 private restService: RestService
19 ) { }
20
21 listRegistrations (options: {
22 pagination: RestPagination
23 sort: SortMeta
24 search?: string
25 }) {
26 const { pagination, sort, search } = options
27
28 const url = AdminRegistrationService.BASE_REGISTRATION_URL
29
30 let params = new HttpParams()
31 params = this.restService.addRestGetParams(params, pagination, sort)
32
33 if (search) {
34 params = params.append('search', search)
35 }
36
37 return this.authHttp.get<ResultList<UserRegistration>>(url, { params })
38 .pipe(
39 catchError(res => this.restExtractor.handleError(res))
40 )
41 }
42
43 acceptRegistration (options: {
44 registration: UserRegistration
45 moderationResponse: string
46 preventEmailDelivery: boolean
47 }) {
48 const { registration, moderationResponse, preventEmailDelivery } = options
49
50 const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/accept'
51 const body: UserRegistrationUpdateState = { moderationResponse, preventEmailDelivery }
52
53 return this.authHttp.post(url, body)
54 .pipe(catchError(res => this.restExtractor.handleError(res)))
55 }
56
57 rejectRegistration (options: {
58 registration: UserRegistration
59 moderationResponse: string
60 preventEmailDelivery: boolean
61 }) {
62 const { registration, moderationResponse, preventEmailDelivery } = options
63
64 const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/reject'
65 const body: UserRegistrationUpdateState = { moderationResponse, preventEmailDelivery }
66
67 return this.authHttp.post(url, body)
68 .pipe(catchError(res => this.restExtractor.handleError(res)))
69 }
70
71 removeRegistrations (registrationsArg: UserRegistration | UserRegistration[]) {
72 const registrations = arrayify(registrationsArg)
73
74 return from(registrations)
75 .pipe(
76 concatMap(r => this.authHttp.delete(AdminRegistrationService.BASE_REGISTRATION_URL + '/' + r.id)),
77 toArray(),
78 catchError(err => this.restExtractor.handleError(err))
79 )
80 }
81}
diff --git a/client/src/app/+admin/moderation/registration-list/index.ts b/client/src/app/+admin/moderation/registration-list/index.ts
new file mode 100644
index 000000000..060b676a4
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/index.ts
@@ -0,0 +1,4 @@
1export * from './admin-registration.service'
2export * from './process-registration-modal.component'
3export * from './process-registration-validators'
4export * from './registration-list.component'
diff --git a/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.html b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.html
new file mode 100644
index 000000000..8e46b0cf9
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.html
@@ -0,0 +1,74 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">
4 <ng-container *ngIf="isAccept()">Accept {{ registration.username }} registration</ng-container>
5 <ng-container *ngIf="isReject()">Reject {{ registration.username }} registration</ng-container>
6 </h4>
7
8 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
9 </div>
10
11 <form novalidate [formGroup]="form" (ngSubmit)="processRegistration()">
12 <div class="modal-body mb-3">
13
14 <div i18n *ngIf="!registration.emailVerified" class="alert alert-warning">
15 Registration email has not been verified. Email delivery has been disabled by default.
16 </div>
17
18 <div class="description">
19 <ng-container *ngIf="isAccept()">
20 <p i18n>
21 <strong>Accepting</strong>&nbsp;<em>{{ registration.username }}</em> registration will create the account and channel.
22 </p>
23
24 <p *ngIf="isEmailEnabled()" i18n [ngClass]="{ 'text-decoration-line-through': isPreventEmailDeliveryChecked() }">
25 An email will be sent to <em>{{ registration.email }}</em> explaining its account has been created with the moderation response you'll write below.
26 </p>
27
28 <div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
29 Emails are not enabled on this instance so PeerTube won't be able to send an email to <em>{{ registration.email }}</em> explaining its account has been created.
30 </div>
31 </ng-container>
32
33 <ng-container *ngIf="isReject()">
34 <p i18n [ngClass]="{ 'text-decoration-line-through': isPreventEmailDeliveryChecked() }">
35 An email will be sent to <em>{{ registration.email }}</em> explaining its registration request has been <strong>rejected</strong> with the moderation response you'll write below.
36 </p>
37
38 <div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
39 Emails are not enabled on this instance so PeerTube won't be able to send an email to <em>{{ registration.email }}</em> explaining its registration request has been rejected.
40 </div>
41 </ng-container>
42 </div>
43
44 <div class="form-group">
45 <label for="moderationResponse" i18n>Send a message to the user</label>
46
47 <textarea
48 formControlName="moderationResponse" ngbAutofocus name="moderationResponse" id="moderationResponse"
49 [ngClass]="{ 'input-error': formErrors['moderationResponse'] }" class="form-control"
50 ></textarea>
51
52 <div *ngIf="formErrors.moderationResponse" class="form-error">
53 {{ formErrors.moderationResponse }}
54 </div>
55 </div>
56
57 <div class="form-group">
58 <my-peertube-checkbox
59 inputName="preventEmailDelivery" formControlName="preventEmailDelivery" [disabled]="!isEmailEnabled()"
60 i18n-labelText labelText="Prevent email from being sent to the user"
61 ></my-peertube-checkbox>
62 </div>
63 </div>
64
65 <div class="modal-footer inputs">
66 <input
67 type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
68 (click)="hide()" (key.enter)="hide()"
69 >
70
71 <input type="submit" [value]="getSubmitValue()" class="peertube-button orange-button" [disabled]="!form.valid">
72 </div>
73 </form>
74</ng-template>
diff --git a/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.scss b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.scss
new file mode 100644
index 000000000..3e03bed89
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.scss
@@ -0,0 +1,3 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
diff --git a/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts
new file mode 100644
index 000000000..3a7e5dea1
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts
@@ -0,0 +1,122 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, ServerService } from '@app/core'
3import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
6import { UserRegistration } from '@shared/models'
7import { AdminRegistrationService } from './admin-registration.service'
8import { REGISTRATION_MODERATION_RESPONSE_VALIDATOR } from './process-registration-validators'
9
10@Component({
11 selector: 'my-process-registration-modal',
12 templateUrl: './process-registration-modal.component.html',
13 styleUrls: [ './process-registration-modal.component.scss' ]
14})
15export class ProcessRegistrationModalComponent extends FormReactive implements OnInit {
16 @ViewChild('modal', { static: true }) modal: NgbModal
17
18 @Output() registrationProcessed = new EventEmitter()
19
20 registration: UserRegistration
21
22 private openedModal: NgbModalRef
23 private processMode: 'accept' | 'reject'
24
25 constructor (
26 protected formReactiveService: FormReactiveService,
27 private server: ServerService,
28 private modalService: NgbModal,
29 private notifier: Notifier,
30 private registrationService: AdminRegistrationService
31 ) {
32 super()
33 }
34
35 ngOnInit () {
36 this.buildForm({
37 moderationResponse: REGISTRATION_MODERATION_RESPONSE_VALIDATOR,
38 preventEmailDelivery: null
39 })
40 }
41
42 isAccept () {
43 return this.processMode === 'accept'
44 }
45
46 isReject () {
47 return this.processMode === 'reject'
48 }
49
50 openModal (registration: UserRegistration, mode: 'accept' | 'reject') {
51 this.processMode = mode
52 this.registration = registration
53
54 this.form.patchValue({
55 preventEmailDelivery: !this.isEmailEnabled() || registration.emailVerified !== true
56 })
57
58 this.openedModal = this.modalService.open(this.modal, { centered: true })
59 }
60
61 hide () {
62 this.form.reset()
63
64 this.openedModal.close()
65 }
66
67 getSubmitValue () {
68 if (this.isAccept()) {
69 return $localize`Accept registration`
70 }
71
72 return $localize`Reject registration`
73 }
74
75 processRegistration () {
76 if (this.isAccept()) return this.acceptRegistration()
77
78 return this.rejectRegistration()
79 }
80
81 isEmailEnabled () {
82 return this.server.getHTMLConfig().email.enabled
83 }
84
85 isPreventEmailDeliveryChecked () {
86 return this.form.value.preventEmailDelivery
87 }
88
89 private acceptRegistration () {
90 this.registrationService.acceptRegistration({
91 registration: this.registration,
92 moderationResponse: this.form.value.moderationResponse,
93 preventEmailDelivery: this.form.value.preventEmailDelivery
94 }).subscribe({
95 next: () => {
96 this.notifier.success($localize`${this.registration.username} account created`)
97
98 this.registrationProcessed.emit()
99 this.hide()
100 },
101
102 error: err => this.notifier.error(err.message)
103 })
104 }
105
106 private rejectRegistration () {
107 this.registrationService.rejectRegistration({
108 registration: this.registration,
109 moderationResponse: this.form.value.moderationResponse,
110 preventEmailDelivery: this.form.value.preventEmailDelivery
111 }).subscribe({
112 next: () => {
113 this.notifier.success($localize`${this.registration.username} registration rejected`)
114
115 this.registrationProcessed.emit()
116 this.hide()
117 },
118
119 error: err => this.notifier.error(err.message)
120 })
121 }
122}
diff --git a/client/src/app/+admin/moderation/registration-list/process-registration-validators.ts b/client/src/app/+admin/moderation/registration-list/process-registration-validators.ts
new file mode 100644
index 000000000..e01a07d9d
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/process-registration-validators.ts
@@ -0,0 +1,11 @@
1import { Validators } from '@angular/forms'
2import { BuildFormValidator } from '@app/shared/form-validators'
3
4export const REGISTRATION_MODERATION_RESPONSE_VALIDATOR: BuildFormValidator = {
5 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
6 MESSAGES: {
7 required: $localize`Moderation response is required.`,
8 minlength: $localize`Moderation response must be at least 2 characters long.`,
9 maxlength: $localize`Moderation response cannot be more than 3000 characters long.`
10 }
11}
diff --git a/client/src/app/+admin/moderation/registration-list/registration-list.component.html b/client/src/app/+admin/moderation/registration-list/registration-list.component.html
new file mode 100644
index 000000000..a2b888101
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/registration-list.component.html
@@ -0,0 +1,135 @@
1<h1>
2 <my-global-icon iconName="user" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>Registration requests</ng-container>
4</h1>
5
6<p-table
7 [value]="registrations" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
10 [(selection)]="selectedRows" [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} registrations"
12 [expandedRowKeys]="expandedRows"
13>
14 <ng-template pTemplate="caption">
15 <div class="caption">
16 <div class="left-buttons">
17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkActions" [entry]="selectedRows"
20 >
21 </my-action-dropdown>
22 </div>
23
24 <div class="ms-auto">
25 <my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
26 </div>
27 </div>
28 </ng-template>
29
30 <ng-template pTemplate="header">
31 <tr> <!-- header -->
32 <th style="width: 40px">
33 <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
34 </th>
35 <th style="width: 40px;"></th>
36 <th style="width: 150px;"></th>
37 <th i18n>Account</th>
38 <th i18n>Email</th>
39 <th i18n>Channel</th>
40 <th i18n>Registration reason</th>
41 <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
42 <th i18n>Moderation response</th>
43 <th style="width: 150px;" i18n pSortableColumn="createdAt">Requested on <p-sortIcon field="createdAt"></p-sortIcon></th>
44 </tr>
45 </ng-template>
46
47 <ng-template pTemplate="body" let-expanded="expanded" let-registration>
48 <tr [pSelectableRow]="registration">
49 <td class="checkbox-cell">
50 <p-tableCheckbox [value]="registration" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
51 </td>
52
53 <td class="expand-cell" [pRowToggler]="registration">
54 <my-table-expander-icon [expanded]="expanded"></my-table-expander-icon>
55 </td>
56
57 <td class="action-cell">
58 <my-action-dropdown
59 [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
60 i18n-label label="Actions" [actions]="registrationActions" [entry]="registration"
61 ></my-action-dropdown>
62 </td>
63
64 <td>
65 <div class="chip two-lines">
66 <div>
67 <span>{{ registration.username }}</span>
68 <span class="muted">{{ registration.accountDisplayName }}</span>
69 </div>
70 </div>
71 </td>
72
73 <td>
74 <my-user-email-info [entry]="registration" [requiresEmailVerification]="requiresEmailVerification"></my-user-email-info>
75 </td>
76
77 <td>
78 <div class="chip two-lines">
79 <div>
80 <span>{{ registration.channelHandle }}</span>
81 <span class="muted">{{ registration.channelDisplayName }}</span>
82 </div>
83 </div>
84 </td>
85
86 <td container="body" placement="left auto" [ngbTooltip]="registration.registrationReason">
87 {{ registration.registrationReason }}
88 </td>
89
90 <td class="c-hand abuse-states" [pRowToggler]="registration">
91 <my-global-icon *ngIf="isRegistrationAccepted(registration)" [title]="registration.state.label" iconName="tick"></my-global-icon>
92 <my-global-icon *ngIf="isRegistrationRejected(registration)" [title]="registration.state.label" iconName="cross"></my-global-icon>
93 </td>
94
95 <td container="body" placement="left auto" [ngbTooltip]="registration.moderationResponse">
96 {{ registration.moderationResponse }}
97 </td>
98
99 <td class="c-hand" [pRowToggler]="registration">{{ registration.createdAt | date: 'short' }}</td>
100 </tr>
101 </ng-template>
102
103 <ng-template pTemplate="rowexpansion" let-registration>
104 <tr>
105 <td colspan="9">
106 <div class="moderation-expanded">
107 <div class="left">
108 <div class="d-flex">
109 <span class="moderation-expanded-label" i18n>Registration reason:</span>
110 <span class="moderation-expanded-text" [innerHTML]="registration.registrationReasonHTML"></span>
111 </div>
112
113 <div *ngIf="registration.moderationResponse">
114 <span class="moderation-expanded-label" i18n>Moderation response:</span>
115 <span class="moderation-expanded-text" [innerHTML]="registration.moderationResponseHTML"></span>
116 </div>
117 </div>
118 </div>
119 </td>
120 </tr>
121 </ng-template>
122
123 <ng-template pTemplate="emptymessage">
124 <tr>
125 <td colspan="9">
126 <div class="no-results">
127 <ng-container *ngIf="search" i18n>No registrations found matching current filters.</ng-container>
128 <ng-container *ngIf="!search" i18n>No registrations found.</ng-container>
129 </div>
130 </td>
131 </tr>
132 </ng-template>
133</p-table>
134
135<my-process-registration-modal #processRegistrationModal (registrationProcessed)="onRegistrationProcessed()"></my-process-registration-modal>
diff --git a/client/src/app/+admin/moderation/registration-list/registration-list.component.scss b/client/src/app/+admin/moderation/registration-list/registration-list.component.scss
new file mode 100644
index 000000000..9cae08e85
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/registration-list.component.scss
@@ -0,0 +1,7 @@
1@use '_mixins' as *;
2@use '_variables' as *;
3
4my-global-icon {
5 width: 24px;
6 height: 24px;
7}
diff --git a/client/src/app/+admin/moderation/registration-list/registration-list.component.ts b/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
new file mode 100644
index 000000000..ed8fbec51
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
@@ -0,0 +1,151 @@
1import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router'
4import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { prepareIcu } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { DropdownAction } from '@app/shared/shared-main'
8import { UserRegistration, UserRegistrationState } from '@shared/models'
9import { AdminRegistrationService } from './admin-registration.service'
10import { ProcessRegistrationModalComponent } from './process-registration-modal.component'
11
12@Component({
13 selector: 'my-registration-list',
14 templateUrl: './registration-list.component.html',
15 styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './registration-list.component.scss' ]
16})
17export class RegistrationListComponent extends RestTable <UserRegistration> implements OnInit {
18 @ViewChild('processRegistrationModal', { static: true }) processRegistrationModal: ProcessRegistrationModalComponent
19
20 registrations: (UserRegistration & { registrationReasonHTML?: string, moderationResponseHTML?: string })[] = []
21 totalRecords = 0
22 sort: SortMeta = { field: 'createdAt', order: -1 }
23 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
24
25 registrationActions: DropdownAction<UserRegistration>[][] = []
26 bulkActions: DropdownAction<UserRegistration[]>[] = []
27
28 inputFilters: AdvancedInputFilter[] = []
29
30 requiresEmailVerification: boolean
31
32 constructor (
33 protected route: ActivatedRoute,
34 protected router: Router,
35 private server: ServerService,
36 private notifier: Notifier,
37 private markdownRenderer: MarkdownService,
38 private confirmService: ConfirmService,
39 private adminRegistrationService: AdminRegistrationService
40 ) {
41 super()
42
43 this.registrationActions = [
44 [
45 {
46 label: $localize`Accept this request`,
47 handler: registration => this.openRegistrationRequestProcessModal(registration, 'accept'),
48 isDisplayed: registration => registration.state.id === UserRegistrationState.PENDING
49 },
50 {
51 label: $localize`Reject this request`,
52 handler: registration => this.openRegistrationRequestProcessModal(registration, 'reject'),
53 isDisplayed: registration => registration.state.id === UserRegistrationState.PENDING
54 },
55 {
56 label: $localize`Remove this request`,
57 handler: registration => this.removeRegistrations([ registration ])
58 }
59 ]
60 ]
61
62 this.bulkActions = [
63 {
64 label: $localize`Delete`,
65 handler: registrations => this.removeRegistrations(registrations)
66 }
67 ]
68 }
69
70 ngOnInit () {
71 this.initialize()
72
73 this.server.getConfig()
74 .subscribe(config => {
75 this.requiresEmailVerification = config.signup.requiresEmailVerification
76 })
77 }
78
79 getIdentifier () {
80 return 'RegistrationListComponent'
81 }
82
83 isRegistrationAccepted (registration: UserRegistration) {
84 return registration.state.id === UserRegistrationState.ACCEPTED
85 }
86
87 isRegistrationRejected (registration: UserRegistration) {
88 return registration.state.id === UserRegistrationState.REJECTED
89 }
90
91 onRegistrationProcessed () {
92 this.reloadData()
93 }
94
95 protected reloadDataInternal () {
96 this.adminRegistrationService.listRegistrations({
97 pagination: this.pagination,
98 sort: this.sort,
99 search: this.search
100 }).subscribe({
101 next: async resultList => {
102 this.totalRecords = resultList.total
103 this.registrations = resultList.data
104
105 for (const registration of this.registrations) {
106 registration.registrationReasonHTML = await this.toHtml(registration.registrationReason)
107 registration.moderationResponseHTML = await this.toHtml(registration.moderationResponse)
108 }
109 },
110
111 error: err => this.notifier.error(err.message)
112 })
113 }
114
115 private openRegistrationRequestProcessModal (registration: UserRegistration, mode: 'accept' | 'reject') {
116 this.processRegistrationModal.openModal(registration, mode)
117 }
118
119 private async removeRegistrations (registrations: UserRegistration[]) {
120 const icuParams = { count: registrations.length, username: registrations[0].username }
121
122 // eslint-disable-next-line max-len
123 const message = prepareIcu($localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`)(
124 icuParams,
125 $localize`Do you really want to delete these registration requests?`
126 )
127
128 const res = await this.confirmService.confirm(message, $localize`Delete`)
129 if (res === false) return
130
131 this.adminRegistrationService.removeRegistrations(registrations)
132 .subscribe({
133 next: () => {
134 // eslint-disable-next-line max-len
135 const message = prepareIcu($localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`)(
136 icuParams,
137 $localize`Registration requests removed`
138 )
139
140 this.notifier.success(message)
141 this.reloadData()
142 },
143
144 error: err => this.notifier.error(err.message)
145 })
146 }
147
148 private toHtml (text: string) {
149 return this.markdownRenderer.textMarkdownToHTML({ markdown: text })
150 }
151}
diff --git a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts
index efd99e52b..f365a2500 100644
--- a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts
+++ b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts
@@ -159,26 +159,25 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
159 }) 159 })
160 } 160 }
161 161
162 protected reloadData () { 162 protected reloadDataInternal () {
163 this.videoBlocklistService.listBlocks({ 163 this.videoBlocklistService.listBlocks({
164 pagination: this.pagination, 164 pagination: this.pagination,
165 sort: this.sort, 165 sort: this.sort,
166 search: this.search 166 search: this.search
167 }) 167 }).subscribe({
168 .subscribe({ 168 next: async resultList => {
169 next: async resultList => { 169 this.totalRecords = resultList.total
170 this.totalRecords = resultList.total
171 170
172 this.blocklist = resultList.data 171 this.blocklist = resultList.data
173 172
174 for (const element of this.blocklist) { 173 for (const element of this.blocklist) {
175 Object.assign(element, { 174 Object.assign(element, {
176 reasonHtml: await this.toHtml(element.reason) 175 reasonHtml: await this.toHtml(element.reason)
177 }) 176 })
178 } 177 }
179 }, 178 },
180 179
181 error: err => this.notifier.error(err.message) 180 error: err => this.notifier.error(err.message)
182 }) 181 })
183 } 182 }
184} 183}
diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.html b/client/src/app/+admin/overview/comments/video-comment-list.component.html
index d2ca5f700..b0d8131bf 100644
--- a/client/src/app/+admin/overview/comments/video-comment-list.component.html
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.html
@@ -13,14 +13,14 @@
13 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" 13 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
14 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 14 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
15 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} comments" 15 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} comments"
16 [expandedRowKeys]="expandedRows" [(selection)]="selectedComments" 16 [expandedRowKeys]="expandedRows" [(selection)]="selectedRows"
17> 17>
18 <ng-template pTemplate="caption"> 18 <ng-template pTemplate="caption">
19 <div class="caption"> 19 <div class="caption">
20 <div> 20 <div>
21 <my-action-dropdown 21 <my-action-dropdown
22 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 22 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
23 [actions]="bulkCommentActions" [entry]="selectedComments" 23 [actions]="bulkActions" [entry]="selectedRows"
24 > 24 >
25 </my-action-dropdown> 25 </my-action-dropdown>
26 </div> 26 </div>
diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.ts b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
index c95d2ffeb..28efdc076 100644
--- a/client/src/app/+admin/overview/comments/video-comment-list.component.ts
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
@@ -14,7 +14,7 @@ import { prepareIcu } from '@app/helpers'
14 templateUrl: './video-comment-list.component.html', 14 templateUrl: './video-comment-list.component.html',
15 styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-comment-list.component.scss' ] 15 styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-comment-list.component.scss' ]
16}) 16})
17export class VideoCommentListComponent extends RestTable implements OnInit { 17export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> implements OnInit {
18 comments: VideoCommentAdmin[] 18 comments: VideoCommentAdmin[]
19 totalRecords = 0 19 totalRecords = 0
20 sort: SortMeta = { field: 'createdAt', order: -1 } 20 sort: SortMeta = { field: 'createdAt', order: -1 }
@@ -40,8 +40,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
40 } 40 }
41 ] 41 ]
42 42
43 selectedComments: VideoCommentAdmin[] = [] 43 bulkActions: DropdownAction<VideoCommentAdmin[]>[] = []
44 bulkCommentActions: DropdownAction<VideoCommentAdmin[]>[] = []
45 44
46 inputFilters: AdvancedInputFilter[] = [ 45 inputFilters: AdvancedInputFilter[] = [
47 { 46 {
@@ -100,7 +99,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
100 ngOnInit () { 99 ngOnInit () {
101 this.initialize() 100 this.initialize()
102 101
103 this.bulkCommentActions = [ 102 this.bulkActions = [
104 { 103 {
105 label: $localize`Delete`, 104 label: $localize`Delete`,
106 handler: comments => this.removeComments(comments), 105 handler: comments => this.removeComments(comments),
@@ -118,11 +117,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
118 return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true }) 117 return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true })
119 } 118 }
120 119
121 isInSelectionMode () { 120 protected reloadDataInternal () {
122 return this.selectedComments.length !== 0
123 }
124
125 reloadData () {
126 this.videoCommentService.getAdminVideoComments({ 121 this.videoCommentService.getAdminVideoComments({
127 pagination: this.pagination, 122 pagination: this.pagination,
128 sort: this.sort, 123 sort: this.sort,
@@ -162,7 +157,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
162 157
163 error: err => this.notifier.error(err.message), 158 error: err => this.notifier.error(err.message),
164 159
165 complete: () => this.selectedComments = [] 160 complete: () => this.selectedRows = []
166 }) 161 })
167 } 162 }
168 163
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.html b/client/src/app/+admin/overview/users/user-list/user-list.component.html
index a96ce561c..7eb5e0fc7 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.html
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.html
@@ -6,7 +6,7 @@
6<p-table 6<p-table
7 [value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" 7 [value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true" 8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true"
9 [(selection)]="selectedUsers" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" 9 [(selection)]="selectedRows" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users"
12 [expandedRowKeys]="expandedRows" 12 [expandedRowKeys]="expandedRows"
@@ -16,7 +16,7 @@
16 <div class="left-buttons"> 16 <div class="left-buttons">
17 <my-action-dropdown 17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkUserActions" [entry]="selectedUsers" 19 [actions]="bulkActions" [entry]="selectedRows"
20 > 20 >
21 </my-action-dropdown> 21 </my-action-dropdown>
22 22
@@ -95,7 +95,7 @@
95 <div class="chip two-lines"> 95 <div class="chip two-lines">
96 <my-actor-avatar [actor]="user?.account" actorType="account" size="32"></my-actor-avatar> 96 <my-actor-avatar [actor]="user?.account" actorType="account" size="32"></my-actor-avatar>
97 <div> 97 <div>
98 <span class="user-table-primary-text">{{ user.account.displayName }}</span> 98 <span>{{ user.account.displayName }}</span>
99 <span class="muted">{{ user.username }}</span> 99 <span class="muted">{{ user.username }}</span>
100 </div> 100 </div>
101 </div> 101 </div>
@@ -110,23 +110,10 @@
110 <span *ngIf="!user.blocked" class="pt-badge" [ngClass]="getRoleClass(user.role.id)">{{ user.role.label }}</span> 110 <span *ngIf="!user.blocked" class="pt-badge" [ngClass]="getRoleClass(user.role.id)">{{ user.role.label }}</span>
111 </td> 111 </td>
112 112
113 <td *ngIf="isSelected('email')" [title]="user.email"> 113 <td *ngIf="isSelected('email')">
114 <ng-container *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus"> 114 <my-user-email-info [entry]="user" [requiresEmailVerification]="requiresEmailVerification"></my-user-email-info>
115 <a class="table-email" [href]="'mailto:' + user.email">{{ user.email }}</a>
116 </ng-container>
117 </td> 115 </td>
118 116
119 <ng-template #emailWithVerificationStatus>
120 <td *ngIf="user.emailVerified === false; else emailVerifiedNotFalse" i18n-title title="User's email must be verified to login">
121 <em>? {{ user.email }}</em>
122 </td>
123 <ng-template #emailVerifiedNotFalse>
124 <td i18n-title title="User's email is verified / User can login without email verification">
125 &#x2713; {{ user.email }}
126 </td>
127 </ng-template>
128 </ng-template>
129
130 <td *ngIf="isSelected('quota')"> 117 <td *ngIf="isSelected('quota')">
131 <div class="progress" i18n-title title="Total video quota"> 118 <div class="progress" i18n-title title="Total video quota">
132 <div class="progress-bar" role="progressbar" [style]="{ width: getUserVideoQuotaPercentage(user) + '%' }" 119 <div class="progress-bar" role="progressbar" [style]="{ width: getUserVideoQuotaPercentage(user) + '%' }"
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.scss b/client/src/app/+admin/overview/users/user-list/user-list.component.scss
index 23e0d29ee..2a3b955d2 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.scss
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.scss
@@ -10,12 +10,6 @@ tr.banned > td {
10 background-color: lighten($color: $red, $amount: 40) !important; 10 background-color: lighten($color: $red, $amount: 40) !important;
11} 11}
12 12
13.table-email {
14 @include disable-default-a-behaviour;
15
16 color: pvar(--mainForegroundColor);
17}
18
19.banned-info { 13.banned-info {
20 font-style: italic; 14 font-style: italic;
21} 15}
@@ -37,10 +31,6 @@ my-global-icon {
37 width: 18px; 31 width: 18px;
38} 32}
39 33
40.chip {
41 @include chip;
42}
43
44.progress { 34.progress {
45 @include progressbar($small: true); 35 @include progressbar($small: true);
46 36
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.ts b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
index 99987fdff..19420b748 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
@@ -22,7 +22,7 @@ type UserForList = User & {
22 templateUrl: './user-list.component.html', 22 templateUrl: './user-list.component.html',
23 styleUrls: [ './user-list.component.scss' ] 23 styleUrls: [ './user-list.component.scss' ]
24}) 24})
25export class UserListComponent extends RestTable implements OnInit { 25export class UserListComponent extends RestTable <User> implements OnInit {
26 private static readonly LOCAL_STORAGE_SELECTED_COLUMNS_KEY = 'admin-user-list-selected-columns' 26 private static readonly LOCAL_STORAGE_SELECTED_COLUMNS_KEY = 'admin-user-list-selected-columns'
27 27
28 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent 28 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent
@@ -35,8 +35,7 @@ export class UserListComponent extends RestTable implements OnInit {
35 35
36 highlightBannedUsers = false 36 highlightBannedUsers = false
37 37
38 selectedUsers: User[] = [] 38 bulkActions: DropdownAction<User[]>[][] = []
39 bulkUserActions: DropdownAction<User[]>[][] = []
40 columns: { id: string, label: string }[] 39 columns: { id: string, label: string }[]
41 40
42 inputFilters: AdvancedInputFilter[] = [ 41 inputFilters: AdvancedInputFilter[] = [
@@ -95,7 +94,7 @@ export class UserListComponent extends RestTable implements OnInit {
95 94
96 this.initialize() 95 this.initialize()
97 96
98 this.bulkUserActions = [ 97 this.bulkActions = [
99 [ 98 [
100 { 99 {
101 label: $localize`Delete`, 100 label: $localize`Delete`,
@@ -249,7 +248,7 @@ export class UserListComponent extends RestTable implements OnInit {
249 const res = await this.confirmService.confirm(message, $localize`Delete`) 248 const res = await this.confirmService.confirm(message, $localize`Delete`)
250 if (res === false) return 249 if (res === false) return
251 250
252 this.userAdminService.removeUser(users) 251 this.userAdminService.removeUsers(users)
253 .subscribe({ 252 .subscribe({
254 next: () => { 253 next: () => {
255 this.notifier.success( 254 this.notifier.success(
@@ -284,13 +283,7 @@ export class UserListComponent extends RestTable implements OnInit {
284 }) 283 })
285 } 284 }
286 285
287 isInSelectionMode () { 286 protected reloadDataInternal () {
288 return this.selectedUsers.length !== 0
289 }
290
291 protected reloadData () {
292 this.selectedUsers = []
293
294 this.userAdminService.getUsers({ 287 this.userAdminService.getUsers({
295 pagination: this.pagination, 288 pagination: this.pagination,
296 sort: this.sort, 289 sort: this.sort,
diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html
index a6cd2e257..5b8405ad9 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.html
+++ b/client/src/app/+admin/overview/videos/video-list.component.html
@@ -6,7 +6,7 @@
6<p-table 6<p-table
7 [value]="videos" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" 7 [value]="videos" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true" 8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true"
9 [(selection)]="selectedVideos" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" 9 [(selection)]="selectedRows" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} videos" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} videos"
12 [expandedRowKeys]="expandedRows" [ngClass]="{ loading: loading }" 12 [expandedRowKeys]="expandedRows" [ngClass]="{ loading: loading }"
@@ -16,7 +16,7 @@
16 <div class="left-buttons"> 16 <div class="left-buttons">
17 <my-action-dropdown 17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkVideoActions" [entry]="selectedVideos" 19 [actions]="bulkActions" [entry]="selectedRows"
20 > 20 >
21 </my-action-dropdown> 21 </my-action-dropdown>
22 </div> 22 </div>
diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts
index 4d3e9873c..1ea295499 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.ts
+++ b/client/src/app/+admin/overview/videos/video-list.component.ts
@@ -17,7 +17,7 @@ import { VideoAdminService } from './video-admin.service'
17 templateUrl: './video-list.component.html', 17 templateUrl: './video-list.component.html',
18 styleUrls: [ './video-list.component.scss' ] 18 styleUrls: [ './video-list.component.scss' ]
19}) 19})
20export class VideoListComponent extends RestTable implements OnInit { 20export class VideoListComponent extends RestTable <Video> implements OnInit {
21 @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent 21 @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent
22 22
23 videos: Video[] = [] 23 videos: Video[] = []
@@ -26,9 +26,7 @@ export class VideoListComponent extends RestTable implements OnInit {
26 sort: SortMeta = { field: 'publishedAt', order: -1 } 26 sort: SortMeta = { field: 'publishedAt', order: -1 }
27 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 27 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
28 28
29 bulkVideoActions: DropdownAction<Video[]>[][] = [] 29 bulkActions: DropdownAction<Video[]>[][] = []
30
31 selectedVideos: Video[] = []
32 30
33 inputFilters: AdvancedInputFilter[] 31 inputFilters: AdvancedInputFilter[]
34 32
@@ -72,7 +70,7 @@ export class VideoListComponent extends RestTable implements OnInit {
72 70
73 this.inputFilters = this.videoAdminService.buildAdminInputFilter() 71 this.inputFilters = this.videoAdminService.buildAdminInputFilter()
74 72
75 this.bulkVideoActions = [ 73 this.bulkActions = [
76 [ 74 [
77 { 75 {
78 label: $localize`Delete`, 76 label: $localize`Delete`,
@@ -126,10 +124,6 @@ export class VideoListComponent extends RestTable implements OnInit {
126 return 'VideoListComponent' 124 return 'VideoListComponent'
127 } 125 }
128 126
129 isInSelectionMode () {
130 return this.selectedVideos.length !== 0
131 }
132
133 getPrivacyBadgeClass (video: Video) { 127 getPrivacyBadgeClass (video: Video) {
134 if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green' 128 if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green'
135 129
@@ -189,9 +183,23 @@ export class VideoListComponent extends RestTable implements OnInit {
189 return files.reduce((p, f) => p += f.size, 0) 183 return files.reduce((p, f) => p += f.size, 0)
190 } 184 }
191 185
192 reloadData () { 186 async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') {
193 this.selectedVideos = [] 187 const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
188 const res = await this.confirmService.confirm(message, $localize`Delete file`)
189 if (res === false) return
190
191 this.videoService.removeFile(video.uuid, file.id, type)
192 .subscribe({
193 next: () => {
194 this.notifier.success($localize`File removed.`)
195 this.reloadData()
196 },
197
198 error: err => this.notifier.error(err.message)
199 })
200 }
194 201
202 protected reloadDataInternal () {
195 this.loading = true 203 this.loading = true
196 204
197 this.videoAdminService.getAdminVideos({ 205 this.videoAdminService.getAdminVideos({
@@ -209,22 +217,6 @@ export class VideoListComponent extends RestTable implements OnInit {
209 }) 217 })
210 } 218 }
211 219
212 async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') {
213 const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
214 const res = await this.confirmService.confirm(message, $localize`Delete file`)
215 if (res === false) return
216
217 this.videoService.removeFile(video.uuid, file.id, type)
218 .subscribe({
219 next: () => {
220 this.notifier.success($localize`File removed.`)
221 this.reloadData()
222 },
223
224 error: err => this.notifier.error(err.message)
225 })
226 }
227
228 private async removeVideos (videos: Video[]) { 220 private async removeVideos (videos: Video[]) {
229 const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)( 221 const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)(
230 { count: videos.length }, 222 { count: videos.length },
diff --git a/client/src/app/+admin/shared/shared-admin.module.ts b/client/src/app/+admin/shared/shared-admin.module.ts
index bef7d54ef..a5c300d12 100644
--- a/client/src/app/+admin/shared/shared-admin.module.ts
+++ b/client/src/app/+admin/shared/shared-admin.module.ts
@@ -1,5 +1,6 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { SharedMainModule } from '../../shared/shared-main/shared-main.module' 2import { SharedMainModule } from '../../shared/shared-main/shared-main.module'
3import { UserEmailInfoComponent } from './user-email-info.component'
3import { UserRealQuotaInfoComponent } from './user-real-quota-info.component' 4import { UserRealQuotaInfoComponent } from './user-real-quota-info.component'
4 5
5@NgModule({ 6@NgModule({
@@ -8,11 +9,13 @@ import { UserRealQuotaInfoComponent } from './user-real-quota-info.component'
8 ], 9 ],
9 10
10 declarations: [ 11 declarations: [
11 UserRealQuotaInfoComponent 12 UserRealQuotaInfoComponent,
13 UserEmailInfoComponent
12 ], 14 ],
13 15
14 exports: [ 16 exports: [
15 UserRealQuotaInfoComponent 17 UserRealQuotaInfoComponent,
18 UserEmailInfoComponent
16 ], 19 ],
17 20
18 providers: [] 21 providers: []
diff --git a/client/src/app/+admin/shared/user-email-info.component.html b/client/src/app/+admin/shared/user-email-info.component.html
new file mode 100644
index 000000000..244240619
--- /dev/null
+++ b/client/src/app/+admin/shared/user-email-info.component.html
@@ -0,0 +1,13 @@
1<ng-container>
2 <a [href]="'mailto:' + entry.email" [title]="getTitle()">
3 <ng-container *ngIf="!requiresEmailVerification">
4 {{ entry.email }}
5 </ng-container>
6
7 <ng-container *ngIf="requiresEmailVerification">
8 <em *ngIf="!entry.emailVerified">? {{ entry.email }}</em>
9
10 <ng-container *ngIf="entry.emailVerified === true">&#x2713; {{ entry.email }}</ng-container>
11 </ng-container>
12 </a>
13</ng-container>
diff --git a/client/src/app/+admin/shared/user-email-info.component.scss b/client/src/app/+admin/shared/user-email-info.component.scss
new file mode 100644
index 000000000..d34947edd
--- /dev/null
+++ b/client/src/app/+admin/shared/user-email-info.component.scss
@@ -0,0 +1,10 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4a {
5 color: pvar(--mainForegroundColor);
6
7 &:hover {
8 text-decoration: underline;
9 }
10}
diff --git a/client/src/app/+admin/shared/user-email-info.component.ts b/client/src/app/+admin/shared/user-email-info.component.ts
new file mode 100644
index 000000000..e33948b60
--- /dev/null
+++ b/client/src/app/+admin/shared/user-email-info.component.ts
@@ -0,0 +1,20 @@
1import { Component, Input } from '@angular/core'
2import { User, UserRegistration } from '@shared/models/users'
3
4@Component({
5 selector: 'my-user-email-info',
6 templateUrl: './user-email-info.component.html',
7 styleUrls: [ './user-email-info.component.scss' ]
8})
9export class UserEmailInfoComponent {
10 @Input() entry: User | UserRegistration
11 @Input() requiresEmailVerification: boolean
12
13 getTitle () {
14 if (this.entry.emailVerified) {
15 return $localize`User email has been verified`
16 }
17
18 return $localize`User email hasn't been verified`
19 }
20}
diff --git a/client/src/app/+admin/system/jobs/job.service.ts b/client/src/app/+admin/system/jobs/job.service.ts
index ef8ddd3b4..031e2bad8 100644
--- a/client/src/app/+admin/system/jobs/job.service.ts
+++ b/client/src/app/+admin/system/jobs/job.service.ts
@@ -19,7 +19,7 @@ export class JobService {
19 private restExtractor: RestExtractor 19 private restExtractor: RestExtractor
20 ) {} 20 ) {}
21 21
22 getJobs (options: { 22 listJobs (options: {
23 jobState?: JobStateClient 23 jobState?: JobStateClient
24 jobType: JobTypeClient 24 jobType: JobTypeClient
25 pagination: RestPagination 25 pagination: RestPagination
diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts
index b8f3c3a68..6e10c81ff 100644
--- a/client/src/app/+admin/system/jobs/jobs.component.ts
+++ b/client/src/app/+admin/system/jobs/jobs.component.ts
@@ -120,12 +120,12 @@ export class JobsComponent extends RestTable implements OnInit {
120 this.reloadData() 120 this.reloadData()
121 } 121 }
122 122
123 protected reloadData () { 123 protected reloadDataInternal () {
124 let jobState = this.jobState as JobState 124 let jobState = this.jobState as JobState
125 if (this.jobState === 'all') jobState = null 125 if (this.jobState === 'all') jobState = null
126 126
127 this.jobsService 127 this.jobsService
128 .getJobs({ 128 .listJobs({
129 jobState, 129 jobState,
130 jobType: this.jobType, 130 jobType: this.jobType,
131 pagination: this.pagination, 131 pagination: this.pagination,
diff --git a/client/src/app/+login/login.component.ts b/client/src/app/+login/login.component.ts
index c1705807f..c03af38f2 100644
--- a/client/src/app/+login/login.component.ts
+++ b/client/src/app/+login/login.component.ts
@@ -1,3 +1,4 @@
1import { environment } from 'src/environments/environment'
1import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' 2import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' 4import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
@@ -7,8 +8,8 @@ import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-valid
7import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms' 8import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
8import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' 9import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
9import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 10import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
10import { PluginsManager } from '@root-helpers/plugins-manager' 11import { getExternalAuthHref } from '@shared/core-utils'
11import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models' 12import { RegisteredExternalAuthConfig, ServerConfig, ServerErrorCode } from '@shared/models'
12 13
13@Component({ 14@Component({
14 selector: 'my-login', 15 selector: 'my-login',
@@ -119,7 +120,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
119 } 120 }
120 121
121 getAuthHref (auth: RegisteredExternalAuthConfig) { 122 getAuthHref (auth: RegisteredExternalAuthConfig) {
122 return PluginsManager.getExternalAuthHref(auth) 123 return getExternalAuthHref(environment.apiUrl, auth)
123 } 124 }
124 125
125 login () { 126 login () {
@@ -196,6 +197,8 @@ The link will expire within 1 hour.`
196 } 197 }
197 198
198 private handleError (err: any) { 199 private handleError (err: any) {
200 console.log(err)
201
199 if (this.authService.isOTPMissingError(err)) { 202 if (this.authService.isOTPMissingError(err)) {
200 this.otpStep = true 203 this.otpStep = true
201 204
@@ -207,8 +210,26 @@ The link will expire within 1 hour.`
207 return 210 return
208 } 211 }
209 212
210 if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.` 213 if (err.message.includes('credentials are invalid')) {
211 else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.` 214 this.error = $localize`Incorrect username or password.`
212 else this.error = err.message 215 return
216 }
217
218 if (err.message.includes('blocked')) {
219 this.error = $localize`Your account is blocked.`
220 return
221 }
222
223 if (err.body?.code === ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL) {
224 this.error = $localize`This account is awaiting approval by moderators.`
225 return
226 }
227
228 if (err.body?.code === ServerErrorCode.ACCOUNT_APPROVAL_REJECTED) {
229 this.error = $localize`Registration approval has been rejected for this account.`
230 return
231 }
232
233 this.error = err.message
213 } 234 }
214} 235}
diff --git a/client/src/app/+my-library/my-ownership/my-ownership.component.scss b/client/src/app/+my-library/my-ownership/my-ownership.component.scss
index a8450ff1b..98bed226d 100644
--- a/client/src/app/+my-library/my-ownership/my-ownership.component.scss
+++ b/client/src/app/+my-library/my-ownership/my-ownership.component.scss
@@ -2,10 +2,6 @@
2@use '_miniature' as *; 2@use '_miniature' as *;
3@use '_mixins' as *; 3@use '_mixins' as *;
4 4
5.chip {
6 @include chip;
7}
8
9.video-table-video { 5.video-table-video {
10 display: inline-flex; 6 display: inline-flex;
11 7
diff --git a/client/src/app/+my-library/my-ownership/my-ownership.component.ts b/client/src/app/+my-library/my-ownership/my-ownership.component.ts
index 7ea940ceb..8d6a42dfb 100644
--- a/client/src/app/+my-library/my-ownership/my-ownership.component.ts
+++ b/client/src/app/+my-library/my-ownership/my-ownership.component.ts
@@ -59,7 +59,7 @@ export class MyOwnershipComponent extends RestTable implements OnInit {
59 }) 59 })
60 } 60 }
61 61
62 protected reloadData () { 62 protected reloadDataInternal () {
63 return this.videoOwnershipService.getOwnershipChanges(this.pagination, this.sort) 63 return this.videoOwnershipService.getOwnershipChanges(this.pagination, this.sort)
64 .subscribe({ 64 .subscribe({
65 next: resultList => { 65 next: resultList => {
diff --git a/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts
index d18e78201..74dbe222d 100644
--- a/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts
+++ b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts
@@ -68,7 +68,7 @@ export class MyVideoChannelSyncsComponent extends RestTable implements OnInit {
68 ] 68 ]
69 } 69 }
70 70
71 protected reloadData () { 71 protected reloadDataInternal () {
72 this.error = undefined 72 this.error = undefined
73 73
74 this.authService.userInformationLoaded 74 this.authService.userInformationLoaded
diff --git a/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts b/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
index 46d689bd1..7d82f62b9 100644
--- a/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
+++ b/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
@@ -90,7 +90,7 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
90 }) 90 })
91 } 91 }
92 92
93 protected reloadData () { 93 protected reloadDataInternal () {
94 this.videoImportService.getMyVideoImports(this.pagination, this.sort, this.search) 94 this.videoImportService.getMyVideoImports(this.pagination, this.sort, this.search)
95 .subscribe({ 95 .subscribe({
96 next: resultList => { 96 next: resultList => {
diff --git a/client/src/app/+signup/+register/register.component.html b/client/src/app/+signup/+register/register.component.html
index bafb96a49..86763e801 100644
--- a/client/src/app/+signup/+register/register.component.html
+++ b/client/src/app/+signup/+register/register.component.html
@@ -5,29 +5,34 @@
5 </div> 5 </div>
6 6
7 <ng-container *ngIf="!signupDisabled"> 7 <ng-container *ngIf="!signupDisabled">
8 <h1 i18n class="title-page-v2"> 8 <h1 class="title-page-v2">
9 <strong class="underline-orange">{{ instanceName }}</strong> 9 <strong class="underline-orange">{{ instanceName }}</strong>
10 > 10 >
11 Create an account 11 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
12 </h1> 12 </h1>
13 13
14 <div class="register-content"> 14 <div class="register-content">
15 <my-custom-stepper linear> 15 <my-custom-stepper linear>
16 16
17 <cdk-step i18n-label label="About" [editable]="!signupSuccess"> 17 <cdk-step i18n-label label="About" [editable]="!signupSuccess">
18 <my-signup-step-title mascotImageName="about" i18n> 18 <my-signup-step-title mascotImageName="about">
19 <strong>Create an account</strong> 19 <strong>
20 <div>on {{ instanceName }}</div> 20 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
21 </strong>
22
23 <div i18n>on {{ instanceName }}</div>
21 </my-signup-step-title> 24 </my-signup-step-title>
22 25
23 <my-register-step-about [videoUploadDisabled]="videoUploadDisabled"></my-register-step-about> 26 <my-register-step-about [requiresApproval]="requiresApproval" [videoUploadDisabled]="videoUploadDisabled"></my-register-step-about>
24 27
25 <div class="step-buttons"> 28 <div class="step-buttons">
26 <a i18n class="skip-step underline-orange" routerLink="/login"> 29 <a i18n class="skip-step underline-orange" routerLink="/login">
27 <strong>I already have an account</strong>, I log in 30 <strong>I already have an account</strong>, I log in
28 </a> 31 </a>
29 32
30 <button i18n cdkStepperNext>Create an account</button> 33 <button cdkStepperNext>
34 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
35 </button>
31 </div> 36 </div>
32 </cdk-step> 37 </cdk-step>
33 38
@@ -44,8 +49,8 @@
44 ></my-instance-about-accordion> 49 ></my-instance-about-accordion>
45 50
46 <my-register-step-terms 51 <my-register-step-terms
47 [hasCodeOfConduct]="!!aboutHtml.codeOfConduct" 52 [hasCodeOfConduct]="!!aboutHtml.codeOfConduct" [minimumAge]="minimumAge" [instanceName]="instanceName"
48 [minimumAge]="minimumAge" 53 [requiresApproval]="requiresApproval"
49 (formBuilt)="onTermsFormBuilt($event)" (termsClick)="onTermsClick()" (codeOfConductClick)="onCodeOfConductClick()" 54 (formBuilt)="onTermsFormBuilt($event)" (termsClick)="onTermsClick()" (codeOfConductClick)="onCodeOfConductClick()"
50 ></my-register-step-terms> 55 ></my-register-step-terms>
51 56
@@ -94,14 +99,15 @@
94 <div class="skip-step-description" i18n>You will be able to create a channel later</div> 99 <div class="skip-step-description" i18n>You will be able to create a channel later</div>
95 </div> 100 </div>
96 101
97 <button cdkStepperNext [disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()" (click)="signup()" i18n> 102 <button cdkStepperNext [disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()" (click)="signup()">
98 Create my account 103 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
99 </button> 104 </button>
100 </div> 105 </div>
101 </cdk-step> 106 </cdk-step>
102 107
103 <cdk-step #lastStep i18n-label label="Done!" [editable]="false"> 108 <cdk-step #lastStep i18n-label label="Done!" [editable]="false">
104 <div *ngIf="!signupSuccess && !signupError" class="done-loader"> 109 <!-- Account creation can be a little bit long so display a loader -->
110 <div *ngIf="!requiresApproval && !signupSuccess && !signupError" class="done-loader">
105 <my-loader [loading]="true"></my-loader> 111 <my-loader [loading]="true"></my-loader>
106 112
107 <div i18n>PeerTube is creating your account...</div> 113 <div i18n>PeerTube is creating your account...</div>
@@ -109,7 +115,10 @@
109 115
110 <div *ngIf="signupError" class="alert alert-danger">{{ signupError }}</div> 116 <div *ngIf="signupError" class="alert alert-danger">{{ signupError }}</div>
111 117
112 <my-signup-success *ngIf="signupSuccess" [requiresEmailVerification]="requiresEmailVerification"></my-signup-success> 118 <my-signup-success-before-email
119 *ngIf="signupSuccess"
120 [requiresEmailVerification]="requiresEmailVerification" [requiresApproval]="requiresApproval" [instanceName]="instanceName"
121 ></my-signup-success-before-email>
113 122
114 <div *ngIf="signupError" class="steps-button"> 123 <div *ngIf="signupError" class="steps-button">
115 <button cdkStepperPrevious>{{ defaultPreviousStepButtonLabel }}</button> 124 <button cdkStepperPrevious>{{ defaultPreviousStepButtonLabel }}</button>
diff --git a/client/src/app/+signup/+register/register.component.ts b/client/src/app/+signup/+register/register.component.ts
index 958770ebf..9259d902c 100644
--- a/client/src/app/+signup/+register/register.component.ts
+++ b/client/src/app/+signup/+register/register.component.ts
@@ -5,10 +5,10 @@ import { ActivatedRoute } from '@angular/router'
5import { AuthService } from '@app/core' 5import { AuthService } from '@app/core'
6import { HooksService } from '@app/core/plugins/hooks.service' 6import { HooksService } from '@app/core/plugins/hooks.service'
7import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' 7import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
8import { UserSignupService } from '@app/shared/shared-users'
9import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap' 8import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap'
10import { UserRegister } from '@shared/models' 9import { UserRegister } from '@shared/models'
11import { ServerConfig } from '@shared/models/server' 10import { ServerConfig } from '@shared/models/server'
11import { SignupService } from '../shared/signup.service'
12 12
13@Component({ 13@Component({
14 selector: 'my-register', 14 selector: 'my-register',
@@ -53,7 +53,7 @@ export class RegisterComponent implements OnInit {
53 constructor ( 53 constructor (
54 private route: ActivatedRoute, 54 private route: ActivatedRoute,
55 private authService: AuthService, 55 private authService: AuthService,
56 private userSignupService: UserSignupService, 56 private signupService: SignupService,
57 private hooks: HooksService 57 private hooks: HooksService
58 ) { } 58 ) { }
59 59
@@ -61,6 +61,10 @@ export class RegisterComponent implements OnInit {
61 return this.serverConfig.signup.requiresEmailVerification 61 return this.serverConfig.signup.requiresEmailVerification
62 } 62 }
63 63
64 get requiresApproval () {
65 return this.serverConfig.signup.requiresApproval
66 }
67
64 get minimumAge () { 68 get minimumAge () {
65 return this.serverConfig.signup.minimumAge 69 return this.serverConfig.signup.minimumAge
66 } 70 }
@@ -132,42 +136,49 @@ export class RegisterComponent implements OnInit {
132 skipChannelCreation () { 136 skipChannelCreation () {
133 this.formStepChannel.reset() 137 this.formStepChannel.reset()
134 this.lastStep.select() 138 this.lastStep.select()
139
135 this.signup() 140 this.signup()
136 } 141 }
137 142
138 async signup () { 143 async signup () {
139 this.signupError = undefined 144 this.signupError = undefined
140 145
141 const body: UserRegister = await this.hooks.wrapObject( 146 const termsForm = this.formStepTerms.value
147 const userForm = this.formStepUser.value
148 const channelForm = this.formStepChannel?.value
149
150 const channel = this.formStepChannel?.value?.name
151 ? { name: channelForm?.name, displayName: channelForm?.displayName }
152 : undefined
153
154 const body = await this.hooks.wrapObject(
142 { 155 {
143 ...this.formStepUser.value, 156 username: userForm.username,
157 password: userForm.password,
158 email: userForm.email,
159 displayName: userForm.displayName,
160
161 registrationReason: termsForm.registrationReason,
144 162
145 channel: this.formStepChannel?.value?.name 163 channel
146 ? this.formStepChannel.value
147 : undefined
148 }, 164 },
149 'signup', 165 'signup',
150 'filter:api.signup.registration.create.params' 166 'filter:api.signup.registration.create.params'
151 ) 167 )
152 168
153 this.userSignupService.signup(body).subscribe({ 169 const obs = this.requiresApproval
170 ? this.signupService.requestSignup(body)
171 : this.signupService.directSignup(body)
172
173 obs.subscribe({
154 next: () => { 174 next: () => {
155 if (this.requiresEmailVerification) { 175 if (this.requiresEmailVerification || this.requiresApproval) {
156 this.signupSuccess = true 176 this.signupSuccess = true
157 return 177 return
158 } 178 }
159 179
160 // Auto login 180 // Auto login
161 this.authService.login({ username: body.username, password: body.password }) 181 this.autoLogin(body)
162 .subscribe({
163 next: () => {
164 this.signupSuccess = true
165 },
166
167 error: err => {
168 this.signupError = err.message
169 }
170 })
171 }, 182 },
172 183
173 error: err => { 184 error: err => {
@@ -175,4 +186,17 @@ export class RegisterComponent implements OnInit {
175 } 186 }
176 }) 187 })
177 } 188 }
189
190 private autoLogin (body: UserRegister) {
191 this.authService.login({ username: body.username, password: body.password })
192 .subscribe({
193 next: () => {
194 this.signupSuccess = true
195 },
196
197 error: err => {
198 this.signupError = err.message
199 }
200 })
201 }
178} 202}
diff --git a/client/src/app/+signup/+register/shared/index.ts b/client/src/app/+signup/+register/shared/index.ts
new file mode 100644
index 000000000..affb54bf4
--- /dev/null
+++ b/client/src/app/+signup/+register/shared/index.ts
@@ -0,0 +1 @@
export * from './register-validators'
diff --git a/client/src/app/+signup/+register/shared/register-validators.ts b/client/src/app/+signup/+register/shared/register-validators.ts
new file mode 100644
index 000000000..f14803b68
--- /dev/null
+++ b/client/src/app/+signup/+register/shared/register-validators.ts
@@ -0,0 +1,18 @@
1import { Validators } from '@angular/forms'
2import { BuildFormValidator } from '@app/shared/form-validators'
3
4export const REGISTER_TERMS_VALIDATOR: BuildFormValidator = {
5 VALIDATORS: [ Validators.requiredTrue ],
6 MESSAGES: {
7 required: $localize`You must agree with the instance terms in order to register on it.`
8 }
9}
10
11export const REGISTER_REASON_VALIDATOR: BuildFormValidator = {
12 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
13 MESSAGES: {
14 required: $localize`Registration reason is required.`,
15 minlength: $localize`Registration reason must be at least 2 characters long.`,
16 maxlength: $localize`Registration reason cannot be more than 3000 characters long.`
17 }
18}
diff --git a/client/src/app/+signup/+register/steps/register-step-about.component.html b/client/src/app/+signup/+register/steps/register-step-about.component.html
index 769fe3127..580e8a92c 100644
--- a/client/src/app/+signup/+register/steps/register-step-about.component.html
+++ b/client/src/app/+signup/+register/steps/register-step-about.component.html
@@ -13,6 +13,10 @@
13 <li i18n>Have access to your <strong>watch history</strong></li> 13 <li i18n>Have access to your <strong>watch history</strong></li>
14 <li *ngIf="!videoUploadDisabled" i18n>Create your channel to <strong>publish videos</strong></li> 14 <li *ngIf="!videoUploadDisabled" i18n>Create your channel to <strong>publish videos</strong></li>
15 </ul> 15 </ul>
16
17 <p *ngIf="requiresApproval" i18n>
18 Moderators of {{ instanceName }} will have to approve your registration request once you have finished to fill the form.
19 </p>
16</div> 20</div>
17 21
18<div> 22<div>
diff --git a/client/src/app/+signup/+register/steps/register-step-about.component.ts b/client/src/app/+signup/+register/steps/register-step-about.component.ts
index 9a0941016..b176ffa59 100644
--- a/client/src/app/+signup/+register/steps/register-step-about.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-about.component.ts
@@ -7,6 +7,7 @@ import { ServerService } from '@app/core'
7 styleUrls: [ './register-step-about.component.scss' ] 7 styleUrls: [ './register-step-about.component.scss' ]
8}) 8})
9export class RegisterStepAboutComponent { 9export class RegisterStepAboutComponent {
10 @Input() requiresApproval: boolean
10 @Input() videoUploadDisabled: boolean 11 @Input() videoUploadDisabled: boolean
11 12
12 constructor (private serverService: ServerService) { 13 constructor (private serverService: ServerService) {
diff --git a/client/src/app/+signup/+register/steps/register-step-channel.component.ts b/client/src/app/+signup/+register/steps/register-step-channel.component.ts
index df92c5145..478ca0177 100644
--- a/client/src/app/+signup/+register/steps/register-step-channel.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-channel.component.ts
@@ -2,9 +2,9 @@ import { concat, of } from 'rxjs'
2import { pairwise } from 'rxjs/operators' 2import { pairwise } from 'rxjs/operators'
3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
4import { FormGroup } from '@angular/forms' 4import { FormGroup } from '@angular/forms'
5import { SignupService } from '@app/+signup/shared/signup.service'
5import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators' 6import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 7import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7import { UserSignupService } from '@app/shared/shared-users'
8 8
9@Component({ 9@Component({
10 selector: 'my-register-step-channel', 10 selector: 'my-register-step-channel',
@@ -20,7 +20,7 @@ export class RegisterStepChannelComponent extends FormReactive implements OnInit
20 20
21 constructor ( 21 constructor (
22 protected formReactiveService: FormReactiveService, 22 protected formReactiveService: FormReactiveService,
23 private userSignupService: UserSignupService 23 private signupService: SignupService
24 ) { 24 ) {
25 super() 25 super()
26 } 26 }
@@ -51,7 +51,7 @@ export class RegisterStepChannelComponent extends FormReactive implements OnInit
51 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) { 51 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
52 const name = this.form.value['name'] || '' 52 const name = this.form.value['name'] || ''
53 53
54 const newName = this.userSignupService.getNewUsername(oldDisplayName, newDisplayName, name) 54 const newName = this.signupService.getNewUsername(oldDisplayName, newDisplayName, name)
55 this.form.patchValue({ name: newName }) 55 this.form.patchValue({ name: newName })
56 } 56 }
57} 57}
diff --git a/client/src/app/+signup/+register/steps/register-step-terms.component.html b/client/src/app/+signup/+register/steps/register-step-terms.component.html
index cbfb32518..1d753a3f2 100644
--- a/client/src/app/+signup/+register/steps/register-step-terms.component.html
+++ b/client/src/app/+signup/+register/steps/register-step-terms.component.html
@@ -1,4 +1,16 @@
1<form role="form" [formGroup]="form"> 1<form role="form" [formGroup]="form">
2
3 <div *ngIf="requiresApproval" class="form-group">
4 <label i18n for="registrationReason">Why do you want to join {{ instanceName }}?</label>
5
6 <textarea
7 id="registrationReason" formControlName="registrationReason" class="form-control" rows="4"
8 [ngClass]="{ 'input-error': formErrors['registrationReason'] }"
9 ></textarea>
10
11 <div *ngIf="formErrors.registrationReason" class="form-error">{{ formErrors.registrationReason }}</div>
12 </div>
13
2 <div class="form-group"> 14 <div class="form-group">
3 <my-peertube-checkbox inputName="terms" formControlName="terms"> 15 <my-peertube-checkbox inputName="terms" formControlName="terms">
4 <ng-template ptTemplate="label"> 16 <ng-template ptTemplate="label">
@@ -6,7 +18,7 @@
6 I am at least {{ minimumAge }} years old and agree 18 I am at least {{ minimumAge }} years old and agree
7 to the <a class="link-orange" (click)="onTermsClick($event)" href='#'>Terms</a> 19 to the <a class="link-orange" (click)="onTermsClick($event)" href='#'>Terms</a>
8 <ng-container *ngIf="hasCodeOfConduct"> and to the <a class="link-orange" (click)="onCodeOfConductClick($event)" href='#'>Code of Conduct</a></ng-container> 20 <ng-container *ngIf="hasCodeOfConduct"> and to the <a class="link-orange" (click)="onCodeOfConductClick($event)" href='#'>Code of Conduct</a></ng-container>
9 of this instance 21 of {{ instanceName }}
10 </ng-container> 22 </ng-container>
11 </ng-template> 23 </ng-template>
12 </my-peertube-checkbox> 24 </my-peertube-checkbox>
diff --git a/client/src/app/+signup/+register/steps/register-step-terms.component.ts b/client/src/app/+signup/+register/steps/register-step-terms.component.ts
index 2df963b30..1b1fb49ee 100644
--- a/client/src/app/+signup/+register/steps/register-step-terms.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-terms.component.ts
@@ -1,7 +1,7 @@
1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
2import { FormGroup } from '@angular/forms' 2import { FormGroup } from '@angular/forms'
3import { USER_TERMS_VALIDATOR } from '@app/shared/form-validators/user-validators'
4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 3import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
4import { REGISTER_REASON_VALIDATOR, REGISTER_TERMS_VALIDATOR } from '../shared'
5 5
6@Component({ 6@Component({
7 selector: 'my-register-step-terms', 7 selector: 'my-register-step-terms',
@@ -10,7 +10,9 @@ import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
10}) 10})
11export class RegisterStepTermsComponent extends FormReactive implements OnInit { 11export class RegisterStepTermsComponent extends FormReactive implements OnInit {
12 @Input() hasCodeOfConduct = false 12 @Input() hasCodeOfConduct = false
13 @Input() requiresApproval: boolean
13 @Input() minimumAge = 16 14 @Input() minimumAge = 16
15 @Input() instanceName: string
14 16
15 @Output() formBuilt = new EventEmitter<FormGroup>() 17 @Output() formBuilt = new EventEmitter<FormGroup>()
16 @Output() termsClick = new EventEmitter<void>() 18 @Output() termsClick = new EventEmitter<void>()
@@ -28,7 +30,11 @@ export class RegisterStepTermsComponent extends FormReactive implements OnInit {
28 30
29 ngOnInit () { 31 ngOnInit () {
30 this.buildForm({ 32 this.buildForm({
31 terms: USER_TERMS_VALIDATOR 33 terms: REGISTER_TERMS_VALIDATOR,
34
35 registrationReason: this.requiresApproval
36 ? REGISTER_REASON_VALIDATOR
37 : null
32 }) 38 })
33 39
34 setTimeout(() => this.formBuilt.emit(this.form)) 40 setTimeout(() => this.formBuilt.emit(this.form))
diff --git a/client/src/app/+signup/+register/steps/register-step-user.component.ts b/client/src/app/+signup/+register/steps/register-step-user.component.ts
index 822f8f5c5..0a5d2e437 100644
--- a/client/src/app/+signup/+register/steps/register-step-user.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-user.component.ts
@@ -2,6 +2,7 @@ import { concat, of } from 'rxjs'
2import { pairwise } from 'rxjs/operators' 2import { pairwise } from 'rxjs/operators'
3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
4import { FormGroup } from '@angular/forms' 4import { FormGroup } from '@angular/forms'
5import { SignupService } from '@app/+signup/shared/signup.service'
5import { 6import {
6 USER_DISPLAY_NAME_REQUIRED_VALIDATOR, 7 USER_DISPLAY_NAME_REQUIRED_VALIDATOR,
7 USER_EMAIL_VALIDATOR, 8 USER_EMAIL_VALIDATOR,
@@ -9,7 +10,6 @@ import {
9 USER_USERNAME_VALIDATOR 10 USER_USERNAME_VALIDATOR
10} from '@app/shared/form-validators/user-validators' 11} from '@app/shared/form-validators/user-validators'
11import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 12import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
12import { UserSignupService } from '@app/shared/shared-users'
13 13
14@Component({ 14@Component({
15 selector: 'my-register-step-user', 15 selector: 'my-register-step-user',
@@ -24,7 +24,7 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
24 24
25 constructor ( 25 constructor (
26 protected formReactiveService: FormReactiveService, 26 protected formReactiveService: FormReactiveService,
27 private userSignupService: UserSignupService 27 private signupService: SignupService
28 ) { 28 ) {
29 super() 29 super()
30 } 30 }
@@ -57,7 +57,7 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
57 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) { 57 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
58 const username = this.form.value['username'] || '' 58 const username = this.form.value['username'] || ''
59 59
60 const newUsername = this.userSignupService.getNewUsername(oldDisplayName, newDisplayName, username) 60 const newUsername = this.signupService.getNewUsername(oldDisplayName, newDisplayName, username)
61 this.form.patchValue({ username: newUsername }) 61 this.form.patchValue({ username: newUsername })
62 } 62 }
63} 63}
diff --git a/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
index 06905f678..75b599e0e 100644
--- a/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
+++ b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
@@ -1,8 +1,8 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { SignupService } from '@app/+signup/shared/signup.service'
2import { Notifier, RedirectService, ServerService } from '@app/core' 3import { Notifier, RedirectService, ServerService } from '@app/core'
3import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators' 4import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators'
4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { UserSignupService } from '@app/shared/shared-users'
6 6
7@Component({ 7@Component({
8 selector: 'my-verify-account-ask-send-email', 8 selector: 'my-verify-account-ask-send-email',
@@ -15,7 +15,7 @@ export class VerifyAccountAskSendEmailComponent extends FormReactive implements
15 15
16 constructor ( 16 constructor (
17 protected formReactiveService: FormReactiveService, 17 protected formReactiveService: FormReactiveService,
18 private userSignupService: UserSignupService, 18 private signupService: SignupService,
19 private serverService: ServerService, 19 private serverService: ServerService,
20 private notifier: Notifier, 20 private notifier: Notifier,
21 private redirectService: RedirectService 21 private redirectService: RedirectService
@@ -34,7 +34,7 @@ export class VerifyAccountAskSendEmailComponent extends FormReactive implements
34 34
35 askSendVerifyEmail () { 35 askSendVerifyEmail () {
36 const email = this.form.value['verify-email-email'] 36 const email = this.form.value['verify-email-email']
37 this.userSignupService.askSendVerifyEmail(email) 37 this.signupService.askSendVerifyEmail(email)
38 .subscribe({ 38 .subscribe({
39 next: () => { 39 next: () => {
40 this.notifier.success($localize`An email with verification link will be sent to ${email}.`) 40 this.notifier.success($localize`An email with verification link will be sent to ${email}.`)
diff --git a/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html
index 122f3c28c..8c8b1098e 100644
--- a/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html
+++ b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html
@@ -1,14 +1,19 @@
1<div class="margin-content"> 1<div *ngIf="loaded" class="margin-content">
2 <h1 i18n class="title-page">Verify account email confirmation</h1> 2 <h1 i18n class="title-page">Verify email</h1>
3 3
4 <my-signup-success i18n *ngIf="!isPendingEmail && success" [requiresEmailVerification]="false"> 4 <my-signup-success-after-email
5 </my-signup-success> 5 *ngIf="displaySignupSuccess()"
6 [requiresApproval]="isRegistrationRequest() && requiresApproval"
7 >
8 </my-signup-success-after-email>
6 9
7 <div i18n class="alert alert-success" *ngIf="isPendingEmail && success">Email updated.</div> 10 <div i18n class="alert alert-success" *ngIf="!isRegistrationRequest() && isPendingEmail && success">Email updated.</div>
8 11
9 <div class="alert alert-danger" *ngIf="failed"> 12 <div class="alert alert-danger" *ngIf="failed">
10 <span i18n>An error occurred.</span> 13 <span i18n>An error occurred.</span>
11 14
12 <a i18n class="ms-1 link-orange" routerLink="/verify-account/ask-send-email" [queryParams]="{ isPendingEmail: isPendingEmail }">Request new verification email</a> 15 <a i18n class="ms-1 link-orange" routerLink="/verify-account/ask-send-email">
16 Request a new verification email
17 </a>
13 </div> 18 </div>
14</div> 19</div>
diff --git a/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
index 88efce4a1..faf663391 100644
--- a/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
+++ b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
@@ -1,7 +1,7 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { ActivatedRoute } from '@angular/router' 2import { ActivatedRoute } from '@angular/router'
3import { AuthService, Notifier } from '@app/core' 3import { SignupService } from '@app/+signup/shared/signup.service'
4import { UserSignupService } from '@app/shared/shared-users' 4import { AuthService, Notifier, ServerService } from '@app/core'
5 5
6@Component({ 6@Component({
7 selector: 'my-verify-account-email', 7 selector: 'my-verify-account-email',
@@ -13,32 +13,82 @@ export class VerifyAccountEmailComponent implements OnInit {
13 failed = false 13 failed = false
14 isPendingEmail = false 14 isPendingEmail = false
15 15
16 requiresApproval: boolean
17 loaded = false
18
16 private userId: number 19 private userId: number
20 private registrationId: number
17 private verificationString: string 21 private verificationString: string
18 22
19 constructor ( 23 constructor (
20 private userSignupService: UserSignupService, 24 private signupService: SignupService,
25 private server: ServerService,
21 private authService: AuthService, 26 private authService: AuthService,
22 private notifier: Notifier, 27 private notifier: Notifier,
23 private route: ActivatedRoute 28 private route: ActivatedRoute
24 ) { 29 ) {
25 } 30 }
26 31
32 get instanceName () {
33 return this.server.getHTMLConfig().instance.name
34 }
35
27 ngOnInit () { 36 ngOnInit () {
28 const queryParams = this.route.snapshot.queryParams 37 const queryParams = this.route.snapshot.queryParams
38
39 this.server.getConfig().subscribe(config => {
40 this.requiresApproval = config.signup.requiresApproval
41
42 this.loaded = true
43 })
44
29 this.userId = queryParams['userId'] 45 this.userId = queryParams['userId']
46 this.registrationId = queryParams['registrationId']
47
30 this.verificationString = queryParams['verificationString'] 48 this.verificationString = queryParams['verificationString']
49
31 this.isPendingEmail = queryParams['isPendingEmail'] === 'true' 50 this.isPendingEmail = queryParams['isPendingEmail'] === 'true'
32 51
33 if (!this.userId || !this.verificationString) { 52 if (!this.verificationString) {
34 this.notifier.error($localize`Unable to find user id or verification string.`) 53 this.notifier.error($localize`Unable to find verification string in URL query.`)
35 } else { 54 return
36 this.verifyEmail() 55 }
56
57 if (!this.userId && !this.registrationId) {
58 this.notifier.error($localize`Unable to find user id or registration id in URL query.`)
59 return
37 } 60 }
61
62 this.verifyEmail()
63 }
64
65 isRegistrationRequest () {
66 return !!this.registrationId
67 }
68
69 displaySignupSuccess () {
70 if (!this.success) return false
71 if (!this.isRegistrationRequest() && this.isPendingEmail) return false
72
73 return true
38 } 74 }
39 75
40 verifyEmail () { 76 verifyEmail () {
41 this.userSignupService.verifyEmail(this.userId, this.verificationString, this.isPendingEmail) 77 if (this.isRegistrationRequest()) {
78 return this.verifyRegistrationEmail()
79 }
80
81 return this.verifyUserEmail()
82 }
83
84 private verifyUserEmail () {
85 const options = {
86 userId: this.userId,
87 verificationString: this.verificationString,
88 isPendingEmail: this.isPendingEmail
89 }
90
91 this.signupService.verifyUserEmail(options)
42 .subscribe({ 92 .subscribe({
43 next: () => { 93 next: () => {
44 if (this.authService.isLoggedIn()) { 94 if (this.authService.isLoggedIn()) {
@@ -55,4 +105,24 @@ export class VerifyAccountEmailComponent implements OnInit {
55 } 105 }
56 }) 106 })
57 } 107 }
108
109 private verifyRegistrationEmail () {
110 const options = {
111 registrationId: this.registrationId,
112 verificationString: this.verificationString
113 }
114
115 this.signupService.verifyRegistrationEmail(options)
116 .subscribe({
117 next: () => {
118 this.success = true
119 },
120
121 error: err => {
122 this.failed = true
123
124 this.notifier.error(err.message)
125 }
126 })
127 }
58} 128}
diff --git a/client/src/app/+signup/shared/shared-signup.module.ts b/client/src/app/+signup/shared/shared-signup.module.ts
index 0aa08f3e2..0600f0af8 100644
--- a/client/src/app/+signup/shared/shared-signup.module.ts
+++ b/client/src/app/+signup/shared/shared-signup.module.ts
@@ -5,7 +5,9 @@ import { SharedMainModule } from '@app/shared/shared-main'
5import { SharedUsersModule } from '@app/shared/shared-users' 5import { SharedUsersModule } from '@app/shared/shared-users'
6import { SignupMascotComponent } from './signup-mascot.component' 6import { SignupMascotComponent } from './signup-mascot.component'
7import { SignupStepTitleComponent } from './signup-step-title.component' 7import { SignupStepTitleComponent } from './signup-step-title.component'
8import { SignupSuccessComponent } from './signup-success.component' 8import { SignupSuccessBeforeEmailComponent } from './signup-success-before-email.component'
9import { SignupSuccessAfterEmailComponent } from './signup-success-after-email.component'
10import { SignupService } from './signup.service'
9 11
10@NgModule({ 12@NgModule({
11 imports: [ 13 imports: [
@@ -16,7 +18,8 @@ import { SignupSuccessComponent } from './signup-success.component'
16 ], 18 ],
17 19
18 declarations: [ 20 declarations: [
19 SignupSuccessComponent, 21 SignupSuccessBeforeEmailComponent,
22 SignupSuccessAfterEmailComponent,
20 SignupStepTitleComponent, 23 SignupStepTitleComponent,
21 SignupMascotComponent 24 SignupMascotComponent
22 ], 25 ],
@@ -26,12 +29,14 @@ import { SignupSuccessComponent } from './signup-success.component'
26 SharedFormModule, 29 SharedFormModule,
27 SharedGlobalIconModule, 30 SharedGlobalIconModule,
28 31
29 SignupSuccessComponent, 32 SignupSuccessBeforeEmailComponent,
33 SignupSuccessAfterEmailComponent,
30 SignupStepTitleComponent, 34 SignupStepTitleComponent,
31 SignupMascotComponent 35 SignupMascotComponent
32 ], 36 ],
33 37
34 providers: [ 38 providers: [
39 SignupService
35 ] 40 ]
36}) 41})
37export class SharedSignupModule { } 42export class SharedSignupModule { }
diff --git a/client/src/app/+signup/shared/signup-success-after-email.component.html b/client/src/app/+signup/shared/signup-success-after-email.component.html
new file mode 100644
index 000000000..1c3536ada
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success-after-email.component.html
@@ -0,0 +1,21 @@
1<my-signup-step-title mascotImageName="success">
2 <strong i18n>Email verified!</strong>
3</my-signup-step-title>
4
5<div class="alert pt-alert-primary">
6 <ng-container *ngIf="requiresApproval">
7 <p i18n>Your email has been verified and your account request has been sent!</p>
8
9 <p i18n>
10 A moderator will check your registration request soon and you'll receive an email when it will be accepted or rejected.
11 </p>
12 </ng-container>
13
14 <ng-container *ngIf="!requiresApproval">
15 <p i18n>Your email has been verified and your account has been created!</p>
16
17 <p i18n>
18 If you need help to use PeerTube, you can have a look at the <a class="link-orange" href="https://docs.joinpeertube.org/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
19 </p>
20 </ng-container>
21</div>
diff --git a/client/src/app/+signup/shared/signup-success-after-email.component.ts b/client/src/app/+signup/shared/signup-success-after-email.component.ts
new file mode 100644
index 000000000..3d72fdae9
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success-after-email.component.ts
@@ -0,0 +1,10 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-signup-success-after-email',
5 templateUrl: './signup-success-after-email.component.html',
6 styleUrls: [ './signup-success.component.scss' ]
7})
8export class SignupSuccessAfterEmailComponent {
9 @Input() requiresApproval: boolean
10}
diff --git a/client/src/app/+signup/shared/signup-success-before-email.component.html b/client/src/app/+signup/shared/signup-success-before-email.component.html
new file mode 100644
index 000000000..b9668ee82
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success-before-email.component.html
@@ -0,0 +1,35 @@
1<my-signup-step-title mascotImageName="success">
2 <ng-container *ngIf="requiresApproval">
3 <strong i18n>Account request sent</strong>
4 </ng-container>
5
6 <ng-container *ngIf="!requiresApproval" i18n>
7 <strong>Welcome</strong>
8 <div>on {{ instanceName }}</div>
9 </ng-container>
10</my-signup-step-title>
11
12<div class="alert pt-alert-primary">
13 <p *ngIf="requiresApproval" i18n>Your account request has been sent!</p>
14 <p *ngIf="!requiresApproval" i18n>Your account has been created!</p>
15
16 <ng-container *ngIf="requiresEmailVerification">
17 <p i18n *ngIf="requiresApproval">
18 <strong>Check your emails</strong> to validate your account and complete your registration request.
19 </p>
20
21 <p i18n *ngIf="!requiresApproval">
22 <strong>Check your emails</strong> to validate your account and complete your registration.
23 </p>
24 </ng-container>
25
26 <ng-container *ngIf="!requiresEmailVerification">
27 <p i18n *ngIf="requiresApproval">
28 A moderator will check your registration request soon and you'll receive an email when it will be accepted or rejected.
29 </p>
30
31 <p *ngIf="!requiresApproval" i18n>
32 If you need help to use PeerTube, you can have a look at the <a class="link-orange" href="https://docs.joinpeertube.org/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
33 </p>
34 </ng-container>
35</div>
diff --git a/client/src/app/+signup/shared/signup-success-before-email.component.ts b/client/src/app/+signup/shared/signup-success-before-email.component.ts
new file mode 100644
index 000000000..d72462340
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success-before-email.component.ts
@@ -0,0 +1,12 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-signup-success-before-email',
5 templateUrl: './signup-success-before-email.component.html',
6 styleUrls: [ './signup-success.component.scss' ]
7})
8export class SignupSuccessBeforeEmailComponent {
9 @Input() requiresApproval: boolean
10 @Input() requiresEmailVerification: boolean
11 @Input() instanceName: string
12}
diff --git a/client/src/app/+signup/shared/signup-success.component.html b/client/src/app/+signup/shared/signup-success.component.html
deleted file mode 100644
index c14889c72..000000000
--- a/client/src/app/+signup/shared/signup-success.component.html
+++ /dev/null
@@ -1,22 +0,0 @@
1<my-signup-step-title mascotImageName="success" i18n>
2 <strong>Welcome</strong>
3 <div>on {{ instanceName }}</div>
4</my-signup-step-title>
5
6<div class="alert pt-alert-primary">
7 <p i18n>Your account has been created!</p>
8
9 <p i18n *ngIf="requiresEmailVerification">
10 <strong>Check your emails</strong> to validate your account and complete your inscription.
11 </p>
12
13 <ng-container *ngIf="!requiresEmailVerification">
14 <p i18n>
15 If you need help to use PeerTube, you can have a look at the <a class="link-orange" href="https://docs.joinpeertube.org/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
16 </p>
17
18 <p i18n>
19 To help moderators and other users to know <strong>who you are</strong>, don't forget to <a class="link-orange" routerLink="/my-account/settings">set up your account profile</a> by adding an <strong>avatar</strong> and a <strong>description</strong>.
20 </p>
21 </ng-container>
22</div>
diff --git a/client/src/app/+signup/shared/signup-success.component.ts b/client/src/app/+signup/shared/signup-success.component.ts
deleted file mode 100644
index a03f3819d..000000000
--- a/client/src/app/+signup/shared/signup-success.component.ts
+++ /dev/null
@@ -1,19 +0,0 @@
1import { Component, Input } from '@angular/core'
2import { ServerService } from '@app/core'
3
4@Component({
5 selector: 'my-signup-success',
6 templateUrl: './signup-success.component.html',
7 styleUrls: [ './signup-success.component.scss' ]
8})
9export class SignupSuccessComponent {
10 @Input() requiresEmailVerification: boolean
11
12 constructor (private serverService: ServerService) {
13
14 }
15
16 get instanceName () {
17 return this.serverService.getHTMLConfig().instance.name
18 }
19}
diff --git a/client/src/app/shared/shared-users/user-signup.service.ts b/client/src/app/+signup/shared/signup.service.ts
index 46fe34af1..f647298be 100644
--- a/client/src/app/shared/shared-users/user-signup.service.ts
+++ b/client/src/app/+signup/shared/signup.service.ts
@@ -2,17 +2,18 @@ import { catchError, tap } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http' 2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { RestExtractor, UserService } from '@app/core' 4import { RestExtractor, UserService } from '@app/core'
5import { UserRegister } from '@shared/models' 5import { UserRegister, UserRegistrationRequest } from '@shared/models'
6 6
7@Injectable() 7@Injectable()
8export class UserSignupService { 8export class SignupService {
9
9 constructor ( 10 constructor (
10 private authHttp: HttpClient, 11 private authHttp: HttpClient,
11 private restExtractor: RestExtractor, 12 private restExtractor: RestExtractor,
12 private userService: UserService 13 private userService: UserService
13 ) { } 14 ) { }
14 15
15 signup (userCreate: UserRegister) { 16 directSignup (userCreate: UserRegister) {
16 return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate) 17 return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
17 .pipe( 18 .pipe(
18 tap(() => this.userService.setSignupInThisSession(true)), 19 tap(() => this.userService.setSignupInThisSession(true)),
@@ -20,8 +21,21 @@ export class UserSignupService {
20 ) 21 )
21 } 22 }
22 23
23 verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) { 24 requestSignup (userCreate: UserRegistrationRequest) {
24 const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email` 25 return this.authHttp.post(UserService.BASE_USERS_URL + 'registrations/request', userCreate)
26 .pipe(catchError(err => this.restExtractor.handleError(err)))
27 }
28
29 // ---------------------------------------------------------------------------
30
31 verifyUserEmail (options: {
32 userId: number
33 verificationString: string
34 isPendingEmail: boolean
35 }) {
36 const { userId, verificationString, isPendingEmail } = options
37
38 const url = `${UserService.BASE_USERS_URL}${userId}/verify-email`
25 const body = { 39 const body = {
26 verificationString, 40 verificationString,
27 isPendingEmail 41 isPendingEmail
@@ -31,13 +45,28 @@ export class UserSignupService {
31 .pipe(catchError(res => this.restExtractor.handleError(res))) 45 .pipe(catchError(res => this.restExtractor.handleError(res)))
32 } 46 }
33 47
48 verifyRegistrationEmail (options: {
49 registrationId: number
50 verificationString: string
51 }) {
52 const { registrationId, verificationString } = options
53
54 const url = `${UserService.BASE_USERS_URL}registrations/${registrationId}/verify-email`
55 const body = { verificationString }
56
57 return this.authHttp.post(url, body)
58 .pipe(catchError(res => this.restExtractor.handleError(res)))
59 }
60
34 askSendVerifyEmail (email: string) { 61 askSendVerifyEmail (email: string) {
35 const url = UserService.BASE_USERS_URL + '/ask-send-verify-email' 62 const url = UserService.BASE_USERS_URL + 'ask-send-verify-email'
36 63
37 return this.authHttp.post(url, { email }) 64 return this.authHttp.post(url, { email })
38 .pipe(catchError(err => this.restExtractor.handleError(err))) 65 .pipe(catchError(err => this.restExtractor.handleError(err)))
39 } 66 }
40 67
68 // ---------------------------------------------------------------------------
69
41 getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) { 70 getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {
42 // Don't update display name, the user seems to have changed it 71 // Don't update display name, the user seems to have changed it
43 if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername 72 if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index 94853423b..84548de97 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -133,8 +133,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
133 this.loadRouteParams() 133 this.loadRouteParams()
134 this.loadRouteQuery() 134 this.loadRouteQuery()
135 135
136 this.initHotkeys()
137
138 this.theaterEnabled = getStoredTheater() 136 this.theaterEnabled = getStoredTheater()
139 137
140 this.hooks.runAction('action:video-watch.init', 'video-watch') 138 this.hooks.runAction('action:video-watch.init', 'video-watch')
@@ -295,6 +293,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
295 subtitle: queryParams.subtitle, 293 subtitle: queryParams.subtitle,
296 294
297 playerMode: queryParams.mode, 295 playerMode: queryParams.mode,
296 playbackRate: queryParams.playbackRate,
298 peertubeLink: false 297 peertubeLink: false
299 } 298 }
300 299
@@ -406,6 +405,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
406 if (res === false) return this.location.back() 405 if (res === false) return this.location.back()
407 } 406 }
408 407
408 this.buildHotkeysHelp(video)
409
409 this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay }) 410 this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay })
410 .catch(err => logger.error('Cannot build the player', err)) 411 .catch(err => logger.error('Cannot build the player', err))
411 412
@@ -657,6 +658,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
657 muted: urlOptions.muted, 658 muted: urlOptions.muted,
658 loop: urlOptions.loop, 659 loop: urlOptions.loop,
659 subtitle: urlOptions.subtitle, 660 subtitle: urlOptions.subtitle,
661 playbackRate: urlOptions.playbackRate,
660 662
661 peertubeLink: urlOptions.peertubeLink, 663 peertubeLink: urlOptions.peertubeLink,
662 664
@@ -785,33 +787,43 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
785 this.video.viewers = newViewers 787 this.video.viewers = newViewers
786 } 788 }
787 789
788 private initHotkeys () { 790 private buildHotkeysHelp (video: Video) {
791 if (this.hotkeys.length !== 0) {
792 this.hotkeysService.remove(this.hotkeys)
793 }
794
789 this.hotkeys = [ 795 this.hotkeys = [
790 // These hotkeys are managed by the player 796 // These hotkeys are managed by the player
791 new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen`), 797 new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen`),
792 new Hotkey('space', e => e, undefined, $localize`Play/Pause the video`), 798 new Hotkey('space', e => e, undefined, $localize`Play/Pause the video`),
793 new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video`), 799 new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video`),
794 800
795 new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
796
797 new Hotkey('up', e => e, undefined, $localize`Increase the volume`), 801 new Hotkey('up', e => e, undefined, $localize`Increase the volume`),
798 new Hotkey('down', e => e, undefined, $localize`Decrease the volume`), 802 new Hotkey('down', e => e, undefined, $localize`Decrease the volume`),
799 803
800 new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
801 new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
802
803 new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
804 new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
805
806 new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
807 new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`),
808
809 new Hotkey('t', e => { 804 new Hotkey('t', e => {
810 this.theaterEnabled = !this.theaterEnabled 805 this.theaterEnabled = !this.theaterEnabled
811 return false 806 return false
812 }, undefined, $localize`Toggle theater mode`) 807 }, undefined, $localize`Toggle theater mode`)
813 ] 808 ]
814 809
810 if (!video.isLive) {
811 this.hotkeys = this.hotkeys.concat([
812 // These hotkeys are also managed by the player but only for VOD
813
814 new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
815
816 new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
817 new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
818
819 new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
820 new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
821
822 new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
823 new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`)
824 ])
825 }
826
815 if (this.isUserLoggedIn()) { 827 if (this.isUserLoggedIn()) {
816 this.hotkeys = this.hotkeys.concat([ 828 this.hotkeys = this.hotkeys.concat([
817 new Hotkey('shift+s', () => { 829 new Hotkey('shift+s', () => {
diff --git a/client/src/app/+videos/video-list/videos-list-common-page.component.ts b/client/src/app/+videos/video-list/videos-list-common-page.component.ts
index c8fa8ef30..bafe30fd7 100644
--- a/client/src/app/+videos/video-list/videos-list-common-page.component.ts
+++ b/client/src/app/+videos/video-list/videos-list-common-page.component.ts
@@ -177,6 +177,9 @@ export class VideosListCommonPageComponent implements OnInit, OnDestroy, Disable
177 case 'best': 177 case 'best':
178 return '-hot' 178 return '-hot'
179 179
180 case 'name':
181 return 'name'
182
180 default: 183 default:
181 return '-' + algorithm as VideoSortField 184 return '-' + algorithm as VideoSortField
182 } 185 }
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts
index 4de28e51e..ed7eabb76 100644
--- a/client/src/app/core/auth/auth.service.ts
+++ b/client/src/app/core/auth/auth.service.ts
@@ -5,10 +5,11 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular
5import { Injectable } from '@angular/core' 5import { Injectable } from '@angular/core'
6import { Router } from '@angular/router' 6import { Router } from '@angular/router'
7import { Notifier } from '@app/core/notification/notifier.service' 7import { Notifier } from '@app/core/notification/notifier.service'
8import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index' 8import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage, PluginsManager } from '@root-helpers/index'
9import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models' 9import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models'
10import { environment } from '../../../environments/environment' 10import { environment } from '../../../environments/environment'
11import { RestExtractor } from '../rest/rest-extractor.service' 11import { RestExtractor } from '../rest/rest-extractor.service'
12import { ServerService } from '../server'
12import { AuthStatus } from './auth-status.model' 13import { AuthStatus } from './auth-status.model'
13import { AuthUser } from './auth-user.model' 14import { AuthUser } from './auth-user.model'
14 15
@@ -44,6 +45,7 @@ export class AuthService {
44 private refreshingTokenObservable: Observable<any> 45 private refreshingTokenObservable: Observable<any>
45 46
46 constructor ( 47 constructor (
48 private serverService: ServerService,
47 private http: HttpClient, 49 private http: HttpClient,
48 private notifier: Notifier, 50 private notifier: Notifier,
49 private hotkeysService: HotkeysService, 51 private hotkeysService: HotkeysService,
@@ -213,25 +215,28 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
213 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') 215 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
214 216
215 this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers }) 217 this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers })
216 .pipe( 218 .pipe(
217 map(res => this.handleRefreshToken(res)), 219 map(res => this.handleRefreshToken(res)),
218 tap(() => { 220 tap(() => {
219 this.refreshingTokenObservable = null 221 this.refreshingTokenObservable = null
220 }), 222 }),
221 catchError(err => { 223 catchError(err => {
222 this.refreshingTokenObservable = null 224 this.refreshingTokenObservable = null
223 225
224 logger.error(err) 226 logger.error(err)
225 logger.info('Cannot refresh token -> logout...') 227 logger.info('Cannot refresh token -> logout...')
226 this.logout() 228 this.logout()
227 this.router.navigate([ '/login' ]) 229
228 230 const externalLoginUrl = PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverService.getHTMLConfig())
229 return observableThrowError(() => ({ 231 if (externalLoginUrl) window.location.href = externalLoginUrl
230 error: $localize`You need to reconnect.` 232 else this.router.navigate([ '/login' ])
231 })) 233
232 }), 234 return observableThrowError(() => ({
233 share() 235 error: $localize`You need to reconnect.`
234 ) 236 }))
237 }),
238 share()
239 )
235 240
236 return this.refreshingTokenObservable 241 return this.refreshingTokenObservable
237 } 242 }
diff --git a/client/src/app/core/renderer/linkifier.service.ts b/client/src/app/core/renderer/linkifier.service.ts
index 78df92cc9..d99591d6c 100644
--- a/client/src/app/core/renderer/linkifier.service.ts
+++ b/client/src/app/core/renderer/linkifier.service.ts
@@ -15,7 +15,7 @@ export class LinkifierService {
15 }, 15 },
16 formatHref: { 16 formatHref: {
17 mention: (href: string) => { 17 mention: (href: string) => {
18 return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + href.substr(1) 18 return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + href.substring(1)
19 } 19 }
20 } 20 }
21 } 21 }
diff --git a/client/src/app/core/renderer/markdown.service.ts b/client/src/app/core/renderer/markdown.service.ts
index a5fd72862..dd23a1b01 100644
--- a/client/src/app/core/renderer/markdown.service.ts
+++ b/client/src/app/core/renderer/markdown.service.ts
@@ -64,8 +64,8 @@ export class MarkdownService {
64 64
65 textMarkdownToHTML (options: { 65 textMarkdownToHTML (options: {
66 markdown: string 66 markdown: string
67 withHtml?: boolean 67 withHtml?: boolean // default false
68 withEmoji?: boolean 68 withEmoji?: boolean // default false
69 }) { 69 }) {
70 const { markdown, withHtml = false, withEmoji = false } = options 70 const { markdown, withHtml = false, withEmoji = false } = options
71 71
@@ -76,8 +76,8 @@ export class MarkdownService {
76 76
77 enhancedMarkdownToHTML (options: { 77 enhancedMarkdownToHTML (options: {
78 markdown: string 78 markdown: string
79 withHtml?: boolean 79 withHtml?: boolean // default false
80 withEmoji?: boolean 80 withEmoji?: boolean // default false
81 }) { 81 }) {
82 const { markdown, withHtml = false, withEmoji = false } = options 82 const { markdown, withHtml = false, withEmoji = false } = options
83 83
@@ -99,6 +99,8 @@ export class MarkdownService {
99 return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags }) 99 return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags })
100 } 100 }
101 101
102 // ---------------------------------------------------------------------------
103
102 processVideoTimestamps (videoShortUUID: string, html: string) { 104 processVideoTimestamps (videoShortUUID: string, html: string) {
103 return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) { 105 return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
104 const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0)) 106 const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts
index de3f2bfff..daed7f178 100644
--- a/client/src/app/core/rest/rest-extractor.service.ts
+++ b/client/src/app/core/rest/rest-extractor.service.ts
@@ -87,7 +87,11 @@ export class RestExtractor {
87 87
88 if (err.status !== undefined) { 88 if (err.status !== undefined) {
89 const errorMessage = this.buildServerErrorMessage(err) 89 const errorMessage = this.buildServerErrorMessage(err)
90 logger.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) 90
91 const message = `Backend returned code ${err.status}, errorMessage is: ${errorMessage}`
92
93 if (err.status === HttpStatusCode.NOT_FOUND_404) logger.clientError(message)
94 else logger.error(message)
91 95
92 return errorMessage 96 return errorMessage
93 } 97 }
diff --git a/client/src/app/core/rest/rest-table.ts b/client/src/app/core/rest/rest-table.ts
index ec5646b5d..707110d7f 100644
--- a/client/src/app/core/rest/rest-table.ts
+++ b/client/src/app/core/rest/rest-table.ts
@@ -7,7 +7,7 @@ import { RestPagination } from './rest-pagination'
7 7
8const debugLogger = debug('peertube:tables:RestTable') 8const debugLogger = debug('peertube:tables:RestTable')
9 9
10export abstract class RestTable { 10export abstract class RestTable <T = unknown> {
11 11
12 abstract totalRecords: number 12 abstract totalRecords: number
13 abstract sort: SortMeta 13 abstract sort: SortMeta
@@ -17,6 +17,8 @@ export abstract class RestTable {
17 rowsPerPage = this.rowsPerPageOptions[0] 17 rowsPerPage = this.rowsPerPageOptions[0]
18 expandedRows = {} 18 expandedRows = {}
19 19
20 selectedRows: T[] = []
21
20 search: string 22 search: string
21 23
22 protected route: ActivatedRoute 24 protected route: ActivatedRoute
@@ -75,7 +77,17 @@ export abstract class RestTable {
75 this.reloadData() 77 this.reloadData()
76 } 78 }
77 79
78 protected abstract reloadData (): void 80 isInSelectionMode () {
81 return this.selectedRows.length !== 0
82 }
83
84 protected abstract reloadDataInternal (): void
85
86 protected reloadData () {
87 this.selectedRows = []
88
89 this.reloadDataInternal()
90 }
79 91
80 private getSortLocalStorageKey () { 92 private getSortLocalStorageKey () {
81 return 'rest-table-sort-' + this.getIdentifier() 93 return 'rest-table-sort-' + this.getIdentifier()
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index c5d08ab75..15b1a3c4a 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -103,7 +103,9 @@
103 <a i18n *ngIf="!getExternalLoginHref()" routerLink="/login" class="peertube-button-link orange-button">Login</a> 103 <a i18n *ngIf="!getExternalLoginHref()" routerLink="/login" class="peertube-button-link orange-button">Login</a>
104 <a i18n *ngIf="getExternalLoginHref()" [href]="getExternalLoginHref()" class="peertube-button-link orange-button">Login</a> 104 <a i18n *ngIf="getExternalLoginHref()" [href]="getExternalLoginHref()" class="peertube-button-link orange-button">Login</a>
105 105
106 <a i18n *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button">Create an account</a> 106 <a *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button">
107 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
108 </a>
107 </div> 109 </div>
108 110
109 <ng-container *ngFor="let menuSection of menuSections" > 111 <ng-container *ngFor="let menuSection of menuSections" >
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts
index 63f01df92..fc6d74cff 100644
--- a/client/src/app/menu/menu.component.ts
+++ b/client/src/app/menu/menu.component.ts
@@ -1,6 +1,7 @@
1import { HotkeysService } from 'angular2-hotkeys' 1import { HotkeysService } from 'angular2-hotkeys'
2import * as debug from 'debug' 2import * as debug from 'debug'
3import { switchMap } from 'rxjs/operators' 3import { switchMap } from 'rxjs/operators'
4import { environment } from 'src/environments/environment'
4import { ViewportScroller } from '@angular/common' 5import { ViewportScroller } from '@angular/common'
5import { Component, OnInit, ViewChild } from '@angular/core' 6import { Component, OnInit, ViewChild } from '@angular/core'
6import { Router } from '@angular/router' 7import { Router } from '@angular/router'
@@ -91,6 +92,10 @@ export class MenuComponent implements OnInit {
91 return this.languageChooserModal.getCurrentLanguage() 92 return this.languageChooserModal.getCurrentLanguage()
92 } 93 }
93 94
95 get requiresApproval () {
96 return this.serverConfig.signup.requiresApproval
97 }
98
94 ngOnInit () { 99 ngOnInit () {
95 this.htmlServerConfig = this.serverService.getHTMLConfig() 100 this.htmlServerConfig = this.serverService.getHTMLConfig()
96 this.currentInterfaceLanguage = this.languageChooserModal.getCurrentLanguage() 101 this.currentInterfaceLanguage = this.languageChooserModal.getCurrentLanguage()
@@ -131,12 +136,7 @@ export class MenuComponent implements OnInit {
131 } 136 }
132 137
133 getExternalLoginHref () { 138 getExternalLoginHref () {
134 if (!this.serverConfig || this.serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined 139 return PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverConfig)
135
136 const externalAuths = this.serverConfig.plugin.registeredExternalAuths
137 if (externalAuths.length !== 1) return undefined
138
139 return PluginsManager.getExternalAuthHref(externalAuths[0])
140 } 140 }
141 141
142 isRegistrationAllowed () { 142 isRegistrationAllowed () {
diff --git a/client/src/app/shared/form-validators/form-validator.model.ts b/client/src/app/shared/form-validators/form-validator.model.ts
index 31c253b9b..1e4bba86b 100644
--- a/client/src/app/shared/form-validators/form-validator.model.ts
+++ b/client/src/app/shared/form-validators/form-validator.model.ts
@@ -12,5 +12,5 @@ export type BuildFormArgument = {
12} 12}
13 13
14export type BuildFormDefaultValues = { 14export type BuildFormDefaultValues = {
15 [ name: string ]: number | string | string[] | BuildFormDefaultValues 15 [ name: string ]: boolean | number | string | string[] | BuildFormDefaultValues
16} 16}
diff --git a/client/src/app/shared/form-validators/user-validators.ts b/client/src/app/shared/form-validators/user-validators.ts
index b93de75ea..ed6e0582e 100644
--- a/client/src/app/shared/form-validators/user-validators.ts
+++ b/client/src/app/shared/form-validators/user-validators.ts
@@ -136,13 +136,6 @@ export const USER_DESCRIPTION_VALIDATOR: BuildFormValidator = {
136 } 136 }
137} 137}
138 138
139export const USER_TERMS_VALIDATOR: BuildFormValidator = {
140 VALIDATORS: [ Validators.requiredTrue ],
141 MESSAGES: {
142 required: $localize`You must agree with the instance terms in order to register on it.`
143 }
144}
145
146export const USER_BAN_REASON_VALIDATOR: BuildFormValidator = { 139export const USER_BAN_REASON_VALIDATOR: BuildFormValidator = {
147 VALIDATORS: [ 140 VALIDATORS: [
148 Validators.minLength(3), 141 Validators.minLength(3),
diff --git a/client/src/app/shared/shared-abuse-list/abuse-details.component.html b/client/src/app/shared/shared-abuse-list/abuse-details.component.html
index 089be501d..2d3e26a25 100644
--- a/client/src/app/shared/shared-abuse-list/abuse-details.component.html
+++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.html
@@ -8,7 +8,7 @@
8 8
9 <span class="moderation-expanded-text"> 9 <span class="moderation-expanded-text">
10 <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" 10 <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
11 class="chip" 11 class="chip me-1"
12 > 12 >
13 <my-actor-avatar size="18" [actor]="abuse.reporterAccount" actorType="account"></my-actor-avatar> 13 <my-actor-avatar size="18" [actor]="abuse.reporterAccount" actorType="account"></my-actor-avatar>
14 <div> 14 <div>
@@ -29,7 +29,7 @@
29 <span class="moderation-expanded-label" i18n>Reportee</span> 29 <span class="moderation-expanded-label" i18n>Reportee</span>
30 <span class="moderation-expanded-text"> 30 <span class="moderation-expanded-text">
31 <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }" 31 <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
32 class="chip" 32 class="chip me-1"
33 > 33 >
34 <my-actor-avatar size="18" [actor]="abuse.flaggedAccount" actorType="account"></my-actor-avatar> 34 <my-actor-avatar size="18" [actor]="abuse.flaggedAccount" actorType="account"></my-actor-avatar>
35 <div> 35 <div>
@@ -63,7 +63,7 @@
63 <div *ngIf="predefinedReasons" class="mt-2 d-flex"> 63 <div *ngIf="predefinedReasons" class="mt-2 d-flex">
64 <span> 64 <span>
65 <a *ngFor="let reason of predefinedReasons" [routerLink]="[ '.' ]" 65 <a *ngFor="let reason of predefinedReasons" [routerLink]="[ '.' ]"
66 [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" 66 [queryParams]="{ 'search': 'tag:' + reason.id }" class="pt-badge badge-secondary"
67 > 67 >
68 <div>{{ reason.label }}</div> 68 <div>{{ reason.label }}</div>
69 </a> 69 </a>
diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
index 569a37b17..d8470e927 100644
--- a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
+++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
@@ -175,7 +175,7 @@ export class AbuseListTableComponent extends RestTable implements OnInit {
175 return Actor.IS_LOCAL(abuse.reporterAccount.host) 175 return Actor.IS_LOCAL(abuse.reporterAccount.host)
176 } 176 }
177 177
178 protected reloadData () { 178 protected reloadDataInternal () {
179 debugLogger('Loading data.') 179 debugLogger('Loading data.')
180 180
181 const options = { 181 const options = {
diff --git a/client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts b/client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts
index 4e802b14d..b2ee2d8f2 100644
--- a/client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts
+++ b/client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts
@@ -6,9 +6,9 @@ import { CustomMarkupService } from './custom-markup.service'
6 templateUrl: './custom-markup-container.component.html' 6 templateUrl: './custom-markup-container.component.html'
7}) 7})
8export class CustomMarkupContainerComponent implements OnChanges { 8export class CustomMarkupContainerComponent implements OnChanges {
9 @ViewChild('contentWrapper') contentWrapper: ElementRef<HTMLInputElement> 9 @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef<HTMLInputElement>
10 10
11 @Input() content: string 11 @Input() content: string | HTMLDivElement
12 12
13 displayed = false 13 displayed = false
14 14
@@ -17,17 +17,23 @@ export class CustomMarkupContainerComponent implements OnChanges {
17 ) { } 17 ) { }
18 18
19 async ngOnChanges () { 19 async ngOnChanges () {
20 await this.buildElement() 20 await this.rebuild()
21 } 21 }
22 22
23 private async buildElement () { 23 private async rebuild () {
24 if (!this.content) return 24 if (this.content instanceof HTMLDivElement) {
25 return this.loadElement(this.content)
26 }
25 27
26 const { rootElement, componentsLoaded } = await this.customMarkupService.buildElement(this.content) 28 const { rootElement, componentsLoaded } = await this.customMarkupService.buildElement(this.content)
27 this.contentWrapper.nativeElement.appendChild(rootElement)
28
29 await componentsLoaded 29 await componentsLoaded
30 30
31 return this.loadElement(rootElement)
32 }
33
34 private loadElement (el: HTMLDivElement) {
35 this.contentWrapper.nativeElement.appendChild(el)
36
31 this.displayed = true 37 this.displayed = true
32 } 38 }
33} 39}
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts
index 1af060548..264dd9577 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts
@@ -1,4 +1,4 @@
1import { Component, Input } from '@angular/core' 1import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
2import { VideoChannel } from '../../shared-main' 2import { VideoChannel } from '../../shared-main'
3import { CustomMarkupComponent } from './shared' 3import { CustomMarkupComponent } from './shared'
4 4
@@ -9,7 +9,8 @@ import { CustomMarkupComponent } from './shared'
9@Component({ 9@Component({
10 selector: 'my-button-markup', 10 selector: 'my-button-markup',
11 templateUrl: 'button-markup.component.html', 11 templateUrl: 'button-markup.component.html',
12 styleUrls: [ 'button-markup.component.scss' ] 12 styleUrls: [ 'button-markup.component.scss' ],
13 changeDetection: ChangeDetectionStrategy.OnPush
13}) 14})
14export class ButtonMarkupComponent implements CustomMarkupComponent { 15export class ButtonMarkupComponent implements CustomMarkupComponent {
15 @Input() theme: 'primary' | 'secondary' 16 @Input() theme: 'primary' | 'secondary'
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts
index ba12b7139..1e7860750 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts
@@ -1,6 +1,6 @@
1import { from } from 'rxjs' 1import { from } from 'rxjs'
2import { finalize, map, switchMap, tap } from 'rxjs/operators' 2import { finalize, map, switchMap, tap } from 'rxjs/operators'
3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 3import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
4import { MarkdownService, Notifier, UserService } from '@app/core' 4import { MarkdownService, Notifier, UserService } from '@app/core'
5import { FindInBulkService } from '@app/shared/shared-search' 5import { FindInBulkService } from '@app/shared/shared-search'
6import { VideoSortField } from '@shared/models' 6import { VideoSortField } from '@shared/models'
@@ -14,7 +14,8 @@ import { CustomMarkupComponent } from './shared'
14@Component({ 14@Component({
15 selector: 'my-channel-miniature-markup', 15 selector: 'my-channel-miniature-markup',
16 templateUrl: 'channel-miniature-markup.component.html', 16 templateUrl: 'channel-miniature-markup.component.html',
17 styleUrls: [ 'channel-miniature-markup.component.scss' ] 17 styleUrls: [ 'channel-miniature-markup.component.scss' ],
18 changeDetection: ChangeDetectionStrategy.OnPush
18}) 19})
19export class ChannelMiniatureMarkupComponent implements CustomMarkupComponent, OnInit { 20export class ChannelMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
20 @Input() name: string 21 @Input() name: string
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts
index 07fa6fd2d..ab52e7e37 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts
@@ -1,5 +1,5 @@
1import { finalize } from 'rxjs/operators' 1import { finalize } from 'rxjs/operators'
2import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 2import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { FindInBulkService } from '@app/shared/shared-search' 4import { FindInBulkService } from '@app/shared/shared-search'
5import { MiniatureDisplayOptions } from '../../shared-video-miniature' 5import { MiniatureDisplayOptions } from '../../shared-video-miniature'
@@ -13,7 +13,8 @@ import { CustomMarkupComponent } from './shared'
13@Component({ 13@Component({
14 selector: 'my-playlist-miniature-markup', 14 selector: 'my-playlist-miniature-markup',
15 templateUrl: 'playlist-miniature-markup.component.html', 15 templateUrl: 'playlist-miniature-markup.component.html',
16 styleUrls: [ 'playlist-miniature-markup.component.scss' ] 16 styleUrls: [ 'playlist-miniature-markup.component.scss' ],
17 changeDetection: ChangeDetectionStrategy.OnPush
17}) 18})
18export class PlaylistMiniatureMarkupComponent implements CustomMarkupComponent, OnInit { 19export class PlaylistMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
19 @Input() uuid: string 20 @Input() uuid: string
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts
index cbbacf77c..c37666359 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts
@@ -1,5 +1,5 @@
1import { finalize } from 'rxjs/operators' 1import { finalize } from 'rxjs/operators'
2import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 2import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
3import { AuthService, Notifier } from '@app/core' 3import { AuthService, Notifier } from '@app/core'
4import { FindInBulkService } from '@app/shared/shared-search' 4import { FindInBulkService } from '@app/shared/shared-search'
5import { Video } from '../../shared-main' 5import { Video } from '../../shared-main'
@@ -13,7 +13,8 @@ import { CustomMarkupComponent } from './shared'
13@Component({ 13@Component({
14 selector: 'my-video-miniature-markup', 14 selector: 'my-video-miniature-markup',
15 templateUrl: 'video-miniature-markup.component.html', 15 templateUrl: 'video-miniature-markup.component.html',
16 styleUrls: [ 'video-miniature-markup.component.scss' ] 16 styleUrls: [ 'video-miniature-markup.component.scss' ],
17 changeDetection: ChangeDetectionStrategy.OnPush
17}) 18})
18export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnInit { 19export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
19 @Input() uuid: string 20 @Input() uuid: string
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts
index 7d3498d4c..70e88ea51 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts
@@ -1,5 +1,5 @@
1import { finalize } from 'rxjs/operators' 1import { finalize } from 'rxjs/operators'
2import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 2import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
3import { AuthService, Notifier } from '@app/core' 3import { AuthService, Notifier } from '@app/core'
4import { VideoSortField } from '@shared/models' 4import { VideoSortField } from '@shared/models'
5import { Video, VideoService } from '../../shared-main' 5import { Video, VideoService } from '../../shared-main'
@@ -13,7 +13,8 @@ import { CustomMarkupComponent } from './shared'
13@Component({ 13@Component({
14 selector: 'my-videos-list-markup', 14 selector: 'my-videos-list-markup',
15 templateUrl: 'videos-list-markup.component.html', 15 templateUrl: 'videos-list-markup.component.html',
16 styleUrls: [ 'videos-list-markup.component.scss' ] 16 styleUrls: [ 'videos-list-markup.component.scss' ],
17 changeDetection: ChangeDetectionStrategy.OnPush
17}) 18})
18export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit { 19export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit {
19 @Input() sort: string 20 @Input() sort: string
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
index e3371f22c..c6527e169 100644
--- a/client/src/app/shared/shared-forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
@@ -31,6 +31,8 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
31 @Input() markdownType: 'text' | 'enhanced' = 'text' 31 @Input() markdownType: 'text' | 'enhanced' = 'text'
32 @Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement> 32 @Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
33 33
34 @Input() debounceTime = 150
35
34 @Input() markdownVideo: Video 36 @Input() markdownVideo: Video
35 37
36 @Input() name = 'description' 38 @Input() name = 'description'
@@ -59,7 +61,7 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
59 ngOnInit () { 61 ngOnInit () {
60 this.contentChanged 62 this.contentChanged
61 .pipe( 63 .pipe(
62 debounceTime(150), 64 debounceTime(this.debounceTime),
63 distinctUntilChanged() 65 distinctUntilChanged()
64 ) 66 )
65 .subscribe(() => this.updatePreviews()) 67 .subscribe(() => this.updatePreviews())
diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.html b/client/src/app/shared/shared-instance/instance-features-table.component.html
index 6c05764df..205f2bc97 100644
--- a/client/src/app/shared/shared-instance/instance-features-table.component.html
+++ b/client/src/app/shared/shared-instance/instance-features-table.component.html
@@ -18,10 +18,9 @@
18 </tr> 18 </tr>
19 19
20 <tr> 20 <tr>
21 <th i18n class="label" scope="row">User registration allowed</th> 21 <th i18n class="label" scope="row">User registration</th>
22 <td> 22
23 <my-feature-boolean [value]="serverConfig.signup.allowed"></my-feature-boolean> 23 <td class="value">{{ buildRegistrationLabel() }}</td>
24 </td>
25 </tr> 24 </tr>
26 25
27 <tr> 26 <tr>
diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.ts b/client/src/app/shared/shared-instance/instance-features-table.component.ts
index e405c5790..c3df7c594 100644
--- a/client/src/app/shared/shared-instance/instance-features-table.component.ts
+++ b/client/src/app/shared/shared-instance/instance-features-table.component.ts
@@ -56,6 +56,15 @@ export class InstanceFeaturesTableComponent implements OnInit {
56 if (policy === 'display') return $localize`Displayed` 56 if (policy === 'display') return $localize`Displayed`
57 } 57 }
58 58
59 buildRegistrationLabel () {
60 const config = this.serverConfig.signup
61
62 if (config.allowed !== true) return $localize`Disabled`
63 if (config.requiresApproval === true) return $localize`Requires approval by moderators`
64
65 return $localize`Enabled`
66 }
67
59 getServerVersionAndCommit () { 68 getServerVersionAndCommit () {
60 return this.serverService.getServerVersionAndCommit() 69 return this.serverService.getServerVersionAndCommit()
61 } 70 }
diff --git a/client/src/app/shared/shared-instance/instance.service.ts b/client/src/app/shared/shared-instance/instance.service.ts
index 89f47db24..f5b2e05db 100644
--- a/client/src/app/shared/shared-instance/instance.service.ts
+++ b/client/src/app/shared/shared-instance/instance.service.ts
@@ -7,6 +7,11 @@ import { peertubeTranslate } from '@shared/core-utils/i18n'
7import { About } from '@shared/models' 7import { About } from '@shared/models'
8import { environment } from '../../../environments/environment' 8import { environment } from '../../../environments/environment'
9 9
10export type AboutHTML = Pick<About['instance'],
11'terms' | 'codeOfConduct' | 'moderationInformation' | 'administrator' | 'creationReason' |
12'maintenanceLifetime' | 'businessModel' | 'hardwareInformation'
13>
14
10@Injectable() 15@Injectable()
11export class InstanceService { 16export class InstanceService {
12 private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config' 17 private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
@@ -39,7 +44,7 @@ export class InstanceService {
39 } 44 }
40 45
41 async buildHtml (about: About) { 46 async buildHtml (about: About) {
42 const html = { 47 const html: AboutHTML = {
43 terms: '', 48 terms: '',
44 codeOfConduct: '', 49 codeOfConduct: '',
45 moderationInformation: '', 50 moderationInformation: '',
diff --git a/client/src/app/shared/shared-main/account/index.ts b/client/src/app/shared/shared-main/account/index.ts
index b80ddb9f5..dd41a5f05 100644
--- a/client/src/app/shared/shared-main/account/index.ts
+++ b/client/src/app/shared/shared-main/account/index.ts
@@ -1,3 +1,4 @@
1export * from './account.model' 1export * from './account.model'
2export * from './account.service' 2export * from './account.service'
3export * from './actor.model' 3export * from './actor.model'
4export * from './signup-label.component'
diff --git a/client/src/app/shared/shared-main/account/signup-label.component.html b/client/src/app/shared/shared-main/account/signup-label.component.html
new file mode 100644
index 000000000..35d6c5360
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/signup-label.component.html
@@ -0,0 +1,2 @@
1<ng-container i18n *ngIf="requiresApproval">Request an account</ng-container>
2<ng-container i18n *ngIf="!requiresApproval">Create an account</ng-container>
diff --git a/client/src/app/shared/shared-main/account/signup-label.component.ts b/client/src/app/shared/shared-main/account/signup-label.component.ts
new file mode 100644
index 000000000..caacb9c6f
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/signup-label.component.ts
@@ -0,0 +1,9 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-signup-label',
5 templateUrl: './signup-label.component.html'
6})
7export class SignupLabelComponent {
8 @Input() requiresApproval: boolean
9}
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index c1523bc50..eb1642d97 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -16,7 +16,7 @@ import {
16import { LoadingBarModule } from '@ngx-loading-bar/core' 16import { LoadingBarModule } from '@ngx-loading-bar/core'
17import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client' 17import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
18import { SharedGlobalIconModule } from '../shared-icons' 18import { SharedGlobalIconModule } from '../shared-icons'
19import { AccountService } from './account' 19import { AccountService, SignupLabelComponent } from './account'
20import { 20import {
21 AutofocusDirective, 21 AutofocusDirective,
22 BytesPipe, 22 BytesPipe,
@@ -113,6 +113,8 @@ import { VideoChannelService } from './video-channel'
113 UserQuotaComponent, 113 UserQuotaComponent,
114 UserNotificationsComponent, 114 UserNotificationsComponent,
115 115
116 SignupLabelComponent,
117
116 EmbedComponent, 118 EmbedComponent,
117 119
118 PluginPlaceholderComponent, 120 PluginPlaceholderComponent,
@@ -171,6 +173,8 @@ import { VideoChannelService } from './video-channel'
171 UserQuotaComponent, 173 UserQuotaComponent,
172 UserNotificationsComponent, 174 UserNotificationsComponent,
173 175
176 SignupLabelComponent,
177
174 EmbedComponent, 178 EmbedComponent,
175 179
176 PluginPlaceholderComponent, 180 PluginPlaceholderComponent,
diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts
index bf8870a79..96e7b4dd0 100644
--- a/client/src/app/shared/shared-main/users/user-notification.model.ts
+++ b/client/src/app/shared/shared-main/users/user-notification.model.ts
@@ -83,6 +83,11 @@ export class UserNotification implements UserNotificationServer {
83 latestVersion: string 83 latestVersion: string
84 } 84 }
85 85
86 registration?: {
87 id: number
88 username: string
89 }
90
86 createdAt: string 91 createdAt: string
87 updatedAt: string 92 updatedAt: string
88 93
@@ -97,6 +102,8 @@ export class UserNotification implements UserNotificationServer {
97 102
98 accountUrl?: string 103 accountUrl?: string
99 104
105 registrationsUrl?: string
106
100 videoImportIdentifier?: string 107 videoImportIdentifier?: string
101 videoImportUrl?: string 108 videoImportUrl?: string
102 109
@@ -135,6 +142,7 @@ export class UserNotification implements UserNotificationServer {
135 142
136 this.plugin = hash.plugin 143 this.plugin = hash.plugin
137 this.peertube = hash.peertube 144 this.peertube = hash.peertube
145 this.registration = hash.registration
138 146
139 this.createdAt = hash.createdAt 147 this.createdAt = hash.createdAt
140 this.updatedAt = hash.updatedAt 148 this.updatedAt = hash.updatedAt
@@ -208,6 +216,10 @@ export class UserNotification implements UserNotificationServer {
208 this.accountUrl = this.buildAccountUrl(this.account) 216 this.accountUrl = this.buildAccountUrl(this.account)
209 break 217 break
210 218
219 case UserNotificationType.NEW_USER_REGISTRATION_REQUEST:
220 this.registrationsUrl = '/admin/moderation/registrations/list'
221 break
222
211 case UserNotificationType.NEW_FOLLOW: 223 case UserNotificationType.NEW_FOLLOW:
212 this.accountUrl = this.buildAccountUrl(this.actorFollow.follower) 224 this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
213 break 225 break
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html
index e7cdb0183..a51e08292 100644
--- a/client/src/app/shared/shared-main/users/user-notifications.component.html
+++ b/client/src/app/shared/shared-main/users/user-notifications.component.html
@@ -215,6 +215,14 @@
215 </div> 215 </div>
216 </ng-container> 216 </ng-container>
217 217
218 <ng-container *ngSwitchCase="20"> <!-- UserNotificationType.NEW_USER_REGISTRATION_REQUEST -->
219 <my-global-icon iconName="user-add" aria-hidden="true"></my-global-icon>
220
221 <div class="message" i18n>
222 User <a (click)="markAsRead(notification)" [routerLink]="notification.registrationsUrl">{{ notification.registration.username }}</a> wants to register on your instance
223 </div>
224 </ng-container>
225
218 <ng-container *ngSwitchDefault> 226 <ng-container *ngSwitchDefault>
219 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon> 227 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
220 228
diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.scss b/client/src/app/shared/shared-moderation/account-blocklist.component.scss
index 8b1239d34..00aaf3b9c 100644
--- a/client/src/app/shared/shared-moderation/account-blocklist.component.scss
+++ b/client/src/app/shared/shared-moderation/account-blocklist.component.scss
@@ -1,10 +1,6 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3 3
4.chip {
5 @include chip;
6}
7
8.unblock-button { 4.unblock-button {
9 @include peertube-button; 5 @include peertube-button;
10 @include grey-button; 6 @include grey-button;
diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.ts b/client/src/app/shared/shared-moderation/account-blocklist.component.ts
index 9ed00bc12..38dbbff78 100644
--- a/client/src/app/shared/shared-moderation/account-blocklist.component.ts
+++ b/client/src/app/shared/shared-moderation/account-blocklist.component.ts
@@ -48,7 +48,7 @@ export class GenericAccountBlocklistComponent extends RestTable implements OnIni
48 ) 48 )
49 } 49 }
50 50
51 protected reloadData () { 51 protected reloadDataInternal () {
52 const operation = this.mode === BlocklistComponentType.Account 52 const operation = this.mode === BlocklistComponentType.Account
53 ? this.blocklistService.getUserAccountBlocklist({ 53 ? this.blocklistService.getUserAccountBlocklist({
54 pagination: this.pagination, 54 pagination: this.pagination,
diff --git a/client/src/app/shared/shared-moderation/moderation.scss b/client/src/app/shared/shared-moderation/moderation.scss
index eaf5a8250..7c1e308cf 100644
--- a/client/src/app/shared/shared-moderation/moderation.scss
+++ b/client/src/app/shared/shared-moderation/moderation.scss
@@ -40,10 +40,6 @@
40 } 40 }
41} 41}
42 42
43.chip {
44 @include chip;
45}
46
47my-action-dropdown.show { 43my-action-dropdown.show {
48 ::ng-deep .dropdown-root { 44 ::ng-deep .dropdown-root {
49 display: block !important; 45 display: block !important;
diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.scss b/client/src/app/shared/shared-moderation/server-blocklist.component.scss
index e29668a23..1a6b0435f 100644
--- a/client/src/app/shared/shared-moderation/server-blocklist.component.scss
+++ b/client/src/app/shared/shared-moderation/server-blocklist.component.scss
@@ -24,7 +24,3 @@ a {
24.block-button { 24.block-button {
25 @include create-button; 25 @include create-button;
26} 26}
27
28.chip {
29 @include chip;
30}
diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.ts b/client/src/app/shared/shared-moderation/server-blocklist.component.ts
index 1ba7a1b4d..f1bcbd561 100644
--- a/client/src/app/shared/shared-moderation/server-blocklist.component.ts
+++ b/client/src/app/shared/shared-moderation/server-blocklist.component.ts
@@ -75,7 +75,7 @@ export class GenericServerBlocklistComponent extends RestTable implements OnInit
75 }) 75 })
76 } 76 }
77 77
78 protected reloadData () { 78 protected reloadDataInternal () {
79 const operation = this.mode === BlocklistComponentType.Account 79 const operation = this.mode === BlocklistComponentType.Account
80 ? this.blocklistService.getUserServerBlocklist({ 80 ? this.blocklistService.getUserServerBlocklist({
81 pagination: this.pagination, 81 pagination: this.pagination,
diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
index c69a45c25..50dccf862 100644
--- a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
@@ -105,7 +105,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
105 const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`) 105 const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`)
106 if (res === false) return 106 if (res === false) return
107 107
108 this.userAdminService.removeUser(user) 108 this.userAdminService.removeUsers(user)
109 .subscribe({ 109 .subscribe({
110 next: () => { 110 next: () => {
111 this.notifier.success($localize`User ${user.username} deleted.`) 111 this.notifier.success($localize`User ${user.username} deleted.`)
diff --git a/client/src/app/shared/shared-users/index.ts b/client/src/app/shared/shared-users/index.ts
index 20e60486d..95d90e49e 100644
--- a/client/src/app/shared/shared-users/index.ts
+++ b/client/src/app/shared/shared-users/index.ts
@@ -1,5 +1,4 @@
1export * from './user-admin.service' 1export * from './user-admin.service'
2export * from './user-signup.service'
3export * from './two-factor.service' 2export * from './two-factor.service'
4 3
5export * from './shared-users.module' 4export * from './shared-users.module'
diff --git a/client/src/app/shared/shared-users/shared-users.module.ts b/client/src/app/shared/shared-users/shared-users.module.ts
index 5a1675dc9..efffc6026 100644
--- a/client/src/app/shared/shared-users/shared-users.module.ts
+++ b/client/src/app/shared/shared-users/shared-users.module.ts
@@ -1,9 +1,7 @@
1
2import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
3import { SharedMainModule } from '../shared-main/shared-main.module' 2import { SharedMainModule } from '../shared-main/shared-main.module'
4import { TwoFactorService } from './two-factor.service' 3import { TwoFactorService } from './two-factor.service'
5import { UserAdminService } from './user-admin.service' 4import { UserAdminService } from './user-admin.service'
6import { UserSignupService } from './user-signup.service'
7 5
8@NgModule({ 6@NgModule({
9 imports: [ 7 imports: [
@@ -15,7 +13,6 @@ import { UserSignupService } from './user-signup.service'
15 exports: [], 13 exports: [],
16 14
17 providers: [ 15 providers: [
18 UserSignupService,
19 UserAdminService, 16 UserAdminService,
20 TwoFactorService 17 TwoFactorService
21 ] 18 ]
diff --git a/client/src/app/shared/shared-users/user-admin.service.ts b/client/src/app/shared/shared-users/user-admin.service.ts
index 0b04023a3..6224f0bd5 100644
--- a/client/src/app/shared/shared-users/user-admin.service.ts
+++ b/client/src/app/shared/shared-users/user-admin.service.ts
@@ -64,7 +64,7 @@ export class UserAdminService {
64 ) 64 )
65 } 65 }
66 66
67 removeUser (usersArg: UserServerModel | UserServerModel[]) { 67 removeUsers (usersArg: UserServerModel | UserServerModel[]) {
68 const users = arrayify(usersArg) 68 const users = arrayify(usersArg)
69 69
70 return from(users) 70 return from(users)
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 6fdf24b2d..227c12130 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -53,8 +53,8 @@
53 <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container> 53 <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
54 </div> 54 </div>
55 55
56 <div *ngIf="containedInPlaylists" class="video-contained-in-playlists"> 56 <div *ngIf="containedInPlaylists" class="fs-6">
57 <a *ngFor="let playlist of containedInPlaylists" class="chip rectangular bg-secondary text-light" [routerLink]="['/w/p/', playlist.playlistShortUUID]"> 57 <a *ngFor="let playlist of containedInPlaylists" class="pt-badge badge-secondary" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
58 {{ playlist.playlistDisplayName }} 58 {{ playlist.playlistDisplayName }}
59 </a> 59 </a>
60 </div> 60 </div>
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
index ba2adfc5a..a397efdca 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
@@ -4,10 +4,6 @@
4 4
5$more-button-width: 40px; 5$more-button-width: 40px;
6 6
7.chip {
8 @include chip;
9}
10
11.video-miniature { 7.video-miniature {
12 font-size: 14px; 8 font-size: 14px;
13} 9}
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 85c63c173..706227e66 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -314,6 +314,6 @@ export class VideoMiniatureComponent implements OnInit {
314 this.cd.markForCheck() 314 this.cd.markForCheck()
315 }) 315 })
316 316
317 this.videoPlaylistService.runPlaylistCheck(this.video.id) 317 this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
318 } 318 }
319} 319}
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
index d5cdd958e..7b832263e 100644
--- a/client/src/app/shared/shared-video-miniature/videos-list.component.ts
+++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
@@ -1,6 +1,6 @@
1import * as debug from 'debug' 1import * as debug from 'debug'
2import { fromEvent, Observable, Subject, Subscription } from 'rxjs' 2import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
3import { debounceTime, switchMap } from 'rxjs/operators' 3import { concatMap, debounceTime, map, switchMap } from 'rxjs/operators'
4import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core' 4import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
5import { ActivatedRoute } from '@angular/router' 5import { ActivatedRoute } from '@angular/router'
6import { 6import {
@@ -111,6 +111,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
111 111
112 private lastQueryLength: number 112 private lastQueryLength: number
113 113
114 private videoRequests = new Subject<{ reset: boolean, obs: Observable<ResultList<Video>> }>()
115
114 constructor ( 116 constructor (
115 private notifier: Notifier, 117 private notifier: Notifier,
116 private authService: AuthService, 118 private authService: AuthService,
@@ -124,6 +126,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
124 } 126 }
125 127
126 ngOnInit () { 128 ngOnInit () {
129 this.subscribeToVideoRequests()
130
127 const hiddenFilters = this.hideScopeFilter 131 const hiddenFilters = this.hideScopeFilter
128 ? [ 'scope' ] 132 ? [ 'scope' ]
129 : [] 133 : []
@@ -228,30 +232,12 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
228 } 232 }
229 233
230 loadMoreVideos (reset = false) { 234 loadMoreVideos (reset = false) {
231 if (reset) this.hasDoneFirstQuery = false 235 if (reset) {
232 236 this.hasDoneFirstQuery = false
233 this.getVideosObservableFunction(this.pagination, this.filters) 237 this.videos = []
234 .subscribe({ 238 }
235 next: ({ data }) => {
236 this.hasDoneFirstQuery = true
237 this.lastQueryLength = data.length
238
239 if (reset) this.videos = []
240 this.videos = this.videos.concat(data)
241
242 if (this.groupByDate) this.buildGroupedDateLabels()
243
244 this.onDataSubject.next(data)
245 this.videosLoaded.emit(this.videos)
246 },
247
248 error: err => {
249 const message = $localize`Cannot load more videos. Try again later.`
250 239
251 logger.error(message, err) 240 this.videoRequests.next({ reset, obs: this.getVideosObservableFunction(this.pagination, this.filters) })
252 this.notifier.error(message)
253 }
254 })
255 } 241 }
256 242
257 reloadVideos () { 243 reloadVideos () {
@@ -423,4 +409,30 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
423 this.onFiltersChanged(true) 409 this.onFiltersChanged(true)
424 }) 410 })
425 } 411 }
412
413 private subscribeToVideoRequests () {
414 this.videoRequests
415 .pipe(concatMap(({ reset, obs }) => obs.pipe(map(({ data }) => ({ data, reset })))))
416 .subscribe({
417 next: ({ data, reset }) => {
418 this.hasDoneFirstQuery = true
419 this.lastQueryLength = data.length
420
421 if (reset) this.videos = []
422 this.videos = this.videos.concat(data)
423
424 if (this.groupByDate) this.buildGroupedDateLabels()
425
426 this.onDataSubject.next(data)
427 this.videosLoaded.emit(this.videos)
428 },
429
430 error: err => {
431 const message = $localize`Cannot load more videos. Try again later.`
432
433 logger.error(message, err)
434 this.notifier.error(message)
435 }
436 })
437 }
426} 438}
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
index 2fc39fc75..f802416a4 100644
--- a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
@@ -81,7 +81,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
81 .subscribe(result => { 81 .subscribe(result => {
82 this.playlistsData = result.data 82 this.playlistsData = result.data
83 83
84 this.videoPlaylistService.runPlaylistCheck(this.video.id) 84 this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
85 }) 85 })
86 86
87 this.videoPlaylistSearchChanged 87 this.videoPlaylistSearchChanged
@@ -129,7 +129,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
129 .subscribe(playlistsResult => { 129 .subscribe(playlistsResult => {
130 this.playlistsData = playlistsResult.data 130 this.playlistsData = playlistsResult.data
131 131
132 this.videoPlaylistService.runPlaylistCheck(this.video.id) 132 this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
133 }) 133 })
134 } 134 }
135 135
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
index 330a51f91..bc9fb0d74 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
@@ -206,7 +206,15 @@ export class VideoPlaylistService {
206 stopTimestamp: body.stopTimestamp 206 stopTimestamp: body.stopTimestamp
207 }) 207 })
208 208
209 this.runPlaylistCheck(body.videoId) 209 this.runVideoExistsInPlaylistCheck(body.videoId)
210
211 if (this.myAccountPlaylistCache) {
212 const playlist = this.myAccountPlaylistCache.data.find(p => p.id === playlistId)
213 if (!playlist) return
214
215 const otherPlaylists = this.myAccountPlaylistCache.data.filter(p => p !== playlist)
216 this.myAccountPlaylistCache.data = [ playlist, ...otherPlaylists ]
217 }
210 }), 218 }),
211 catchError(err => this.restExtractor.handleError(err)) 219 catchError(err => this.restExtractor.handleError(err))
212 ) 220 )
@@ -225,7 +233,7 @@ export class VideoPlaylistService {
225 elem.stopTimestamp = body.stopTimestamp 233 elem.stopTimestamp = body.stopTimestamp
226 } 234 }
227 235
228 this.runPlaylistCheck(videoId) 236 this.runVideoExistsInPlaylistCheck(videoId)
229 }), 237 }),
230 catchError(err => this.restExtractor.handleError(err)) 238 catchError(err => this.restExtractor.handleError(err))
231 ) 239 )
@@ -242,7 +250,7 @@ export class VideoPlaylistService {
242 .filter(e => e.playlistElementId !== playlistElementId) 250 .filter(e => e.playlistElementId !== playlistElementId)
243 } 251 }
244 252
245 this.runPlaylistCheck(videoId) 253 this.runVideoExistsInPlaylistCheck(videoId)
246 }), 254 }),
247 catchError(err => this.restExtractor.handleError(err)) 255 catchError(err => this.restExtractor.handleError(err))
248 ) 256 )
@@ -296,7 +304,7 @@ export class VideoPlaylistService {
296 return obs 304 return obs
297 } 305 }
298 306
299 runPlaylistCheck (videoId: number) { 307 runVideoExistsInPlaylistCheck (videoId: number) {
300 debugLogger('Running playlist check.') 308 debugLogger('Running playlist check.')
301 309
302 if (this.videoExistsCache[videoId]) { 310 if (this.videoExistsCache[videoId]) {
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index 56310c4e9..2781850b9 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -11,6 +11,7 @@ import './shared/control-bar/p2p-info-button'
11import './shared/control-bar/peertube-link-button' 11import './shared/control-bar/peertube-link-button'
12import './shared/control-bar/peertube-load-progress-bar' 12import './shared/control-bar/peertube-load-progress-bar'
13import './shared/control-bar/theater-button' 13import './shared/control-bar/theater-button'
14import './shared/control-bar/peertube-live-display'
14import './shared/settings/resolution-menu-button' 15import './shared/settings/resolution-menu-button'
15import './shared/settings/resolution-menu-item' 16import './shared/settings/resolution-menu-item'
16import './shared/settings/settings-dialog' 17import './shared/settings/settings-dialog'
@@ -96,6 +97,10 @@ export class PeertubePlayerManager {
96 videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) { 97 videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
97 const player = this 98 const player = this
98 99
100 if (!isNaN(+options.common.playbackRate)) {
101 player.playbackRate(+options.common.playbackRate)
102 }
103
99 let alreadyFallback = false 104 let alreadyFallback = false
100 105
101 const handleError = () => { 106 const handleError = () => {
@@ -118,7 +123,7 @@ export class PeertubePlayerManager {
118 self.addContextMenu(videojsOptionsBuilder, player, options.common) 123 self.addContextMenu(videojsOptionsBuilder, player, options.common)
119 124
120 if (isMobile()) player.peertubeMobile() 125 if (isMobile()) player.peertubeMobile()
121 if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin() 126 if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive })
122 if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden') 127 if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden')
123 128
124 player.bezels() 129 player.bezels()
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts
index db5b8938d..e71e90713 100644
--- a/client/src/assets/player/shared/control-bar/index.ts
+++ b/client/src/assets/player/shared/control-bar/index.ts
@@ -1,5 +1,6 @@
1export * from './next-previous-video-button' 1export * from './next-previous-video-button'
2export * from './p2p-info-button' 2export * from './p2p-info-button'
3export * from './peertube-link-button' 3export * from './peertube-link-button'
4export * from './peertube-live-display'
4export * from './peertube-load-progress-bar' 5export * from './peertube-load-progress-bar'
5export * from './theater-button' 6export * from './theater-button'
diff --git a/client/src/assets/player/shared/control-bar/peertube-live-display.ts b/client/src/assets/player/shared/control-bar/peertube-live-display.ts
new file mode 100644
index 000000000..649eb0b00
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/peertube-live-display.ts
@@ -0,0 +1,93 @@
1import videojs from 'video.js'
2import { PeerTubeLinkButtonOptions } from '../../types'
3
4const ClickableComponent = videojs.getComponent('ClickableComponent')
5
6class PeerTubeLiveDisplay extends ClickableComponent {
7 private interval: any
8
9 private contentEl_: any
10
11 constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) {
12 super(player, options as any)
13
14 this.interval = this.setInterval(() => this.updateClass(), 1000)
15
16 this.show()
17 this.updateSync(true)
18 }
19
20 dispose () {
21 if (this.interval) {
22 this.clearInterval(this.interval)
23 this.interval = undefined
24 }
25
26 this.contentEl_ = null
27
28 super.dispose()
29 }
30
31 createEl () {
32 const el = super.createEl('div', {
33 className: 'vjs-live-control vjs-control'
34 })
35
36 this.contentEl_ = videojs.dom.createEl('div', {
37 className: 'vjs-live-display'
38 }, {
39 'aria-live': 'off'
40 })
41
42 this.contentEl_.appendChild(videojs.dom.createEl('span', {
43 className: 'vjs-control-text',
44 textContent: `${this.localize('Stream Type')}\u00a0`
45 }))
46
47 this.contentEl_.appendChild(document.createTextNode(this.localize('LIVE')))
48
49 el.appendChild(this.contentEl_)
50 return el
51 }
52
53 handleClick () {
54 const hlsjs = this.getHLSJS()
55 if (!hlsjs) return
56
57 this.player().currentTime(hlsjs.liveSyncPosition)
58 this.player().play()
59 this.updateSync(true)
60 }
61
62 private updateClass () {
63 const hlsjs = this.getHLSJS()
64 if (!hlsjs) return
65
66 // Not loaded yet
67 if (this.player().currentTime() === 0) return
68
69 const isSync = Math.abs(this.player().currentTime() - hlsjs.liveSyncPosition) < 10
70 this.updateSync(isSync)
71 }
72
73 private updateSync (isSync: boolean) {
74 if (isSync) {
75 this.addClass('synced-with-live-edge')
76 this.removeAttribute('title')
77 this.disable()
78 } else {
79 this.removeClass('synced-with-live-edge')
80 this.setAttribute('title', this.localize('Go back to the live'))
81 this.enable()
82 }
83 }
84
85 private getHLSJS () {
86 const p2pMediaLoader = this.player()?.p2pMediaLoader
87 if (!p2pMediaLoader) return undefined
88
89 return p2pMediaLoader().getHLSJS()
90 }
91}
92
93videojs.registerComponent('PeerTubeLiveDisplay', PeerTubeLiveDisplay)
diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
index ec1e1038b..f5b4b3919 100644
--- a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
+++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
@@ -4,6 +4,10 @@ type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardE
4 4
5const Plugin = videojs.getPlugin('plugin') 5const Plugin = videojs.getPlugin('plugin')
6 6
7export type HotkeysOptions = {
8 isLive: boolean
9}
10
7class PeerTubeHotkeysPlugin extends Plugin { 11class PeerTubeHotkeysPlugin extends Plugin {
8 private static readonly VOLUME_STEP = 0.1 12 private static readonly VOLUME_STEP = 0.1
9 private static readonly SEEK_STEP = 5 13 private static readonly SEEK_STEP = 5
@@ -12,9 +16,13 @@ class PeerTubeHotkeysPlugin extends Plugin {
12 16
13 private readonly handlers: KeyHandler[] 17 private readonly handlers: KeyHandler[]
14 18
15 constructor (player: videojs.Player, options: videojs.PlayerOptions) { 19 private readonly isLive: boolean
20
21 constructor (player: videojs.Player, options: videojs.PlayerOptions & HotkeysOptions) {
16 super(player, options) 22 super(player, options)
17 23
24 this.isLive = options.isLive
25
18 this.handlers = this.buildHandlers() 26 this.handlers = this.buildHandlers()
19 27
20 this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event) 28 this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event)
@@ -68,28 +76,6 @@ class PeerTubeHotkeysPlugin extends Plugin {
68 } 76 }
69 }, 77 },
70 78
71 // Rewind
72 {
73 accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
74 cb: e => {
75 e.preventDefault()
76
77 const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
78 this.player.currentTime(target)
79 }
80 },
81
82 // Forward
83 {
84 accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
85 cb: e => {
86 e.preventDefault()
87
88 const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
89 this.player.currentTime(target)
90 }
91 },
92
93 // Fullscreen 79 // Fullscreen
94 { 80 {
95 // f key or Ctrl + Enter 81 // f key or Ctrl + Enter
@@ -116,6 +102,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
116 { 102 {
117 accept: e => e.key === '>', 103 accept: e => e.key === '>',
118 cb: () => { 104 cb: () => {
105 if (this.isLive) return
106
119 const target = Math.min(this.player.playbackRate() + 0.1, 5) 107 const target = Math.min(this.player.playbackRate() + 0.1, 5)
120 108
121 this.player.playbackRate(parseFloat(target.toFixed(2))) 109 this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -126,6 +114,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
126 { 114 {
127 accept: e => e.key === '<', 115 accept: e => e.key === '<',
128 cb: () => { 116 cb: () => {
117 if (this.isLive) return
118
129 const target = Math.max(this.player.playbackRate() - 0.1, 0.10) 119 const target = Math.max(this.player.playbackRate() - 0.1, 0.10)
130 120
131 this.player.playbackRate(parseFloat(target.toFixed(2))) 121 this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -136,6 +126,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
136 { 126 {
137 accept: e => e.key === ',', 127 accept: e => e.key === ',',
138 cb: () => { 128 cb: () => {
129 if (this.isLive) return
130
139 this.player.pause() 131 this.player.pause()
140 132
141 // Calculate movement distance (assuming 30 fps) 133 // Calculate movement distance (assuming 30 fps)
@@ -148,6 +140,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
148 { 140 {
149 accept: e => e.key === '.', 141 accept: e => e.key === '.',
150 cb: () => { 142 cb: () => {
143 if (this.isLive) return
144
151 this.player.pause() 145 this.player.pause()
152 146
153 // Calculate movement distance (assuming 30 fps) 147 // Calculate movement distance (assuming 30 fps)
@@ -157,11 +151,47 @@ class PeerTubeHotkeysPlugin extends Plugin {
157 } 151 }
158 ] 152 ]
159 153
154 if (this.isLive) return handlers
155
156 return handlers.concat(this.buildVODHandlers())
157 }
158
159 private buildVODHandlers () {
160 const handlers: KeyHandler[] = [
161 // Rewind
162 {
163 accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
164 cb: e => {
165 if (this.isLive) return
166
167 e.preventDefault()
168
169 const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
170 this.player.currentTime(target)
171 }
172 },
173
174 // Forward
175 {
176 accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
177 cb: e => {
178 if (this.isLive) return
179
180 e.preventDefault()
181
182 const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
183 this.player.currentTime(target)
184 }
185 }
186 ]
187
160 // 0-9 key handlers 188 // 0-9 key handlers
161 for (let i = 0; i < 10; i++) { 189 for (let i = 0; i < 10; i++) {
162 handlers.push({ 190 handlers.push({
163 accept: e => this.isNakedOrShift(e, i + ''), 191 accept: e => this.isNakedOrShift(e, i + ''),
164 cb: e => { 192 cb: e => {
193 if (this.isLive) return
194
165 e.preventDefault() 195 e.preventDefault()
166 196
167 this.player.currentTime(this.player.duration() * i * 0.1) 197 this.player.currentTime(this.player.duration() * i * 0.1)
diff --git a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
index 27f366732..26f923e92 100644
--- a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
+++ b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
@@ -30,10 +30,7 @@ export class ControlBarOptionsBuilder {
30 } 30 }
31 31
32 Object.assign(children, { 32 Object.assign(children, {
33 currentTimeDisplay: {}, 33 ...this.getTimeControls(),
34 timeDivider: {},
35 durationDisplay: {},
36 liveDisplay: {},
37 34
38 flexibleWidthSpacer: {}, 35 flexibleWidthSpacer: {},
39 36
@@ -74,7 +71,9 @@ export class ControlBarOptionsBuilder {
74 private getSettingsButton () { 71 private getSettingsButton () {
75 const settingEntries: string[] = [] 72 const settingEntries: string[] = []
76 73
77 settingEntries.push('playbackRateMenuButton') 74 if (!this.options.isLive) {
75 settingEntries.push('playbackRateMenuButton')
76 }
78 77
79 if (this.options.captions === true) settingEntries.push('captionsButton') 78 if (this.options.captions === true) settingEntries.push('captionsButton')
80 79
@@ -90,7 +89,23 @@ export class ControlBarOptionsBuilder {
90 } 89 }
91 } 90 }
92 91
92 private getTimeControls () {
93 if (this.options.isLive) {
94 return {
95 peerTubeLiveDisplay: {}
96 }
97 }
98
99 return {
100 currentTimeDisplay: {},
101 timeDivider: {},
102 durationDisplay: {}
103 }
104 }
105
93 private getProgressControl () { 106 private getProgressControl () {
107 if (this.options.isLive) return {}
108
94 const loadProgressBar = this.mode === 'webtorrent' 109 const loadProgressBar = this.mode === 'webtorrent'
95 ? 'peerTubeLoadProgressBar' 110 ? 'peerTubeLoadProgressBar'
96 : 'loadProgressBar' 111 : 'loadProgressBar'
diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
index a14beb347..7f7d90ab9 100644
--- a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
+++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
@@ -281,8 +281,8 @@ class Html5Hlsjs {
281 if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1 281 if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1
282 else this.errorCounts[data.type] = 1 282 else this.errorCounts[data.type] = 1
283 283
284 if (data.fatal) logger.warn(error.message) 284 if (data.fatal) logger.error(error.message, { currentTime: this.player.currentTime(), data })
285 else logger.error(error.message, { data }) 285 else logger.warn(error.message)
286 286
287 if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) { 287 if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) {
288 error.code = 2 288 error.code = 2
diff --git a/client/src/assets/player/shared/stats/stats-card.ts b/client/src/assets/player/shared/stats/stats-card.ts
index f23ae48be..471a5e46c 100644
--- a/client/src/assets/player/shared/stats/stats-card.ts
+++ b/client/src/assets/player/shared/stats/stats-card.ts
@@ -182,7 +182,7 @@ class StatsCard extends Component {
182 let colorSpace = 'unknown' 182 let colorSpace = 'unknown'
183 let codecs = 'unknown' 183 let codecs = 'unknown'
184 184
185 if (metadata?.streams[0]) { 185 if (metadata?.streams?.[0]) {
186 const stream = metadata.streams[0] 186 const stream = metadata.streams[0]
187 187
188 colorSpace = stream['color_space'] !== 'unknown' 188 colorSpace = stream['color_space'] !== 'unknown'
@@ -193,7 +193,7 @@ class StatsCard extends Component {
193 } 193 }
194 194
195 const resolution = videoFile?.resolution.label + videoFile?.fps 195 const resolution = videoFile?.resolution.label + videoFile?.fps
196 const buffer = this.timeRangesToString(this.player().buffered()) 196 const buffer = this.timeRangesToString(this.player_.buffered())
197 const progress = this.player_.webtorrent().getTorrent()?.progress 197 const progress = this.player_.webtorrent().getTorrent()?.progress
198 198
199 return { 199 return {
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts
index 3057a5adb..3fbcec29c 100644
--- a/client/src/assets/player/types/manager-options.ts
+++ b/client/src/assets/player/types/manager-options.ts
@@ -29,6 +29,8 @@ export interface CustomizationOptions {
29 resume?: string 29 resume?: string
30 30
31 peertubeLink: boolean 31 peertubeLink: boolean
32
33 playbackRate?: number | string
32} 34}
33 35
34export interface CommonOptions extends CustomizationOptions { 36export interface CommonOptions extends CustomizationOptions {
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts
index c60154f3b..5674f78cb 100644
--- a/client/src/assets/player/types/peertube-videojs-typings.ts
+++ b/client/src/assets/player/types/peertube-videojs-typings.ts
@@ -3,6 +3,7 @@ import videojs from 'video.js'
3import { Engine } from '@peertube/p2p-media-loader-hlsjs' 3import { Engine } from '@peertube/p2p-media-loader-hlsjs'
4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' 4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
5import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' 5import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
6import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin'
6import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' 7import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
7import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' 8import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
8import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' 9import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
@@ -44,7 +45,7 @@ declare module 'video.js' {
44 45
45 bezels (): void 46 bezels (): void
46 peertubeMobile (): void 47 peertubeMobile (): void
47 peerTubeHotkeysPlugin (): void 48 peerTubeHotkeysPlugin (options?: HotkeysOptions): void
48 49
49 stats (options?: StatsCardOptions): StatsForNerdsPlugin 50 stats (options?: StatsCardOptions): StatsForNerdsPlugin
50 51
diff --git a/client/src/root-helpers/logger.ts b/client/src/root-helpers/logger.ts
index d1fdf73aa..618be62cd 100644
--- a/client/src/root-helpers/logger.ts
+++ b/client/src/root-helpers/logger.ts
@@ -27,6 +27,10 @@ class Logger {
27 warn (message: LoggerMessage, meta?: LoggerMeta) { 27 warn (message: LoggerMessage, meta?: LoggerMeta) {
28 this.runHooks('warn', message, meta) 28 this.runHooks('warn', message, meta)
29 29
30 this.clientWarn(message, meta)
31 }
32
33 clientWarn (message: LoggerMessage, meta?: LoggerMeta) {
30 if (meta) console.warn(message, meta) 34 if (meta) console.warn(message, meta)
31 else console.warn(message) 35 else console.warn(message)
32 } 36 }
@@ -34,6 +38,10 @@ class Logger {
34 error (message: LoggerMessage, meta?: LoggerMeta) { 38 error (message: LoggerMessage, meta?: LoggerMeta) {
35 this.runHooks('error', message, meta) 39 this.runHooks('error', message, meta)
36 40
41 this.clientError(message, meta)
42 }
43
44 clientError (message: LoggerMessage, meta?: LoggerMeta) {
37 if (meta) console.error(message, meta) 45 if (meta) console.error(message, meta)
38 else console.error(message) 46 else console.error(message)
39 } 47 }
diff --git a/client/src/root-helpers/plugins-manager.ts b/client/src/root-helpers/plugins-manager.ts
index 6c64e2b01..e5b06a94c 100644
--- a/client/src/root-helpers/plugins-manager.ts
+++ b/client/src/root-helpers/plugins-manager.ts
@@ -3,7 +3,7 @@ import * as debug from 'debug'
3import { firstValueFrom, ReplaySubject } from 'rxjs' 3import { firstValueFrom, ReplaySubject } from 'rxjs'
4import { first, shareReplay } from 'rxjs/operators' 4import { first, shareReplay } from 'rxjs/operators'
5import { RegisterClientHelpers } from 'src/types/register-client-option.model' 5import { RegisterClientHelpers } from 'src/types/register-client-option.model'
6import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' 6import { getExternalAuthHref, getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
7import { 7import {
8 ClientHookName, 8 ClientHookName,
9 clientHookObject, 9 clientHookObject,
@@ -16,7 +16,6 @@ import {
16 RegisterClientRouteOptions, 16 RegisterClientRouteOptions,
17 RegisterClientSettingsScriptOptions, 17 RegisterClientSettingsScriptOptions,
18 RegisterClientVideoFieldOptions, 18 RegisterClientVideoFieldOptions,
19 RegisteredExternalAuthConfig,
20 ServerConfigPlugin 19 ServerConfigPlugin
21} from '@shared/models' 20} from '@shared/models'
22import { environment } from '../environments/environment' 21import { environment } from '../environments/environment'
@@ -94,9 +93,13 @@ class PluginsManager {
94 return isTheme ? '/themes' : '/plugins' 93 return isTheme ? '/themes' : '/plugins'
95 } 94 }
96 95
97 static getExternalAuthHref (auth: RegisteredExternalAuthConfig) { 96 static getDefaultLoginHref (apiUrl: string, serverConfig: HTMLServerConfig) {
98 return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` 97 if (!serverConfig || serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
99 98
99 const externalAuths = serverConfig.plugin.registeredExternalAuths
100 if (externalAuths.length !== 1) return undefined
101
102 return getExternalAuthHref(apiUrl, externalAuths[0])
100 } 103 }
101 104
102 loadPluginsList (config: HTMLServerConfig) { 105 loadPluginsList (config: HTMLServerConfig) {
diff --git a/client/src/sass/class-helpers.scss b/client/src/sass/class-helpers.scss
index bc965331a..feb3a6de2 100644
--- a/client/src/sass/class-helpers.scss
+++ b/client/src/sass/class-helpers.scss
@@ -284,3 +284,9 @@ label + .form-group-description {
284 border: 2px solid pvar(--mainColorLightest); 284 border: 2px solid pvar(--mainColorLightest);
285 } 285 }
286} 286}
287
288// ---------------------------------------------------------------------------
289
290.chip {
291 @include chip;
292}
diff --git a/client/src/sass/include/_badges.scss b/client/src/sass/include/_badges.scss
index 4bc70d4a9..7efd2fb81 100644
--- a/client/src/sass/include/_badges.scss
+++ b/client/src/sass/include/_badges.scss
@@ -9,6 +9,10 @@
9 font-weight: $font-semibold; 9 font-weight: $font-semibold;
10 line-height: 1.1; 10 line-height: 1.1;
11 11
12 &.badge-fs-normal {
13 font-size: 100%;
14 }
15
12 &.badge-primary { 16 &.badge-primary {
13 color: pvar(--mainBackgroundColor); 17 color: pvar(--mainBackgroundColor);
14 background-color: pvar(--mainColor); 18 background-color: pvar(--mainColor);
diff --git a/client/src/sass/include/_fonts.scss b/client/src/sass/include/_fonts.scss
index e5a40af34..514261d01 100644
--- a/client/src/sass/include/_fonts.scss
+++ b/client/src/sass/include/_fonts.scss
@@ -15,7 +15,3 @@
15 font-display: swap; 15 font-display: swap;
16 src: url('../fonts/source-sans/WOFF2/VAR/SourceSans3VF-Italic.ttf.woff2') format('woff2'); 16 src: url('../fonts/source-sans/WOFF2/VAR/SourceSans3VF-Italic.ttf.woff2') format('woff2');
17} 17}
18
19@mixin muted {
20 color: pvar(--greyForegroundColor) !important;
21}
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index b5ccb6598..8816437d9 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -36,6 +36,10 @@
36 max-height: $font-size * $number-of-lines; 36 max-height: $font-size * $number-of-lines;
37} 37}
38 38
39@mixin muted {
40 color: pvar(--greyForegroundColor) !important;
41}
42
39@mixin fade-text ($fade-after, $background-color) { 43@mixin fade-text ($fade-after, $background-color) {
40 position: relative; 44 position: relative;
41 overflow: hidden; 45 overflow: hidden;
@@ -791,51 +795,39 @@
791} 795}
792 796
793@mixin chip { 797@mixin chip {
794 --chip-radius: 5rem; 798 --avatar-size: 1.2rem;
795 --chip-padding: .2rem .4rem;
796 $avatar-height: 1.2rem;
797 799
798 align-items: center;
799 border-radius: var(--chip-radius);
800 display: inline-flex; 800 display: inline-flex;
801 font-size: 90%;
802 color: pvar(--mainForegroundColor); 801 color: pvar(--mainForegroundColor);
803 height: $avatar-height; 802 height: var(--avatar-size);
804 line-height: 1rem;
805 margin: .1rem;
806 max-width: 320px; 803 max-width: 320px;
807 overflow: hidden; 804 overflow: hidden;
808 padding: var(--chip-padding);
809 text-decoration: none; 805 text-decoration: none;
810 text-overflow: ellipsis; 806 text-overflow: ellipsis;
811 vertical-align: middle; 807 vertical-align: middle;
812 white-space: nowrap; 808 white-space: nowrap;
813 809
814 &.rectangular {
815 --chip-radius: .2rem;
816 --chip-padding: .2rem .3rem;
817 }
818
819 my-actor-avatar { 810 my-actor-avatar {
820 @include margin-left(-.4rem);
821 @include margin-right(.2rem); 811 @include margin-right(.2rem);
812
813 border-radius: 5rem;
814 width: var(--avatar-size);
815 height: var(--avatar-size);
822 } 816 }
823 817
824 &.two-lines { 818 &.two-lines {
825 $avatar-height: 2rem; 819 --avatar-size: 2rem;
826 820
827 height: $avatar-height; 821 font-size: 14px;
822 line-height: 1rem;
828 823
829 my-actor-avatar { 824 my-actor-avatar {
830 display: inline-block; 825 display: inline-block;
831 } 826 }
832 827
833 div { 828 > div {
834 margin: 0 .1rem;
835
836 display: flex; 829 display: flex;
837 flex-direction: column; 830 flex-direction: column;
838 height: $avatar-height;
839 justify-content: center; 831 justify-content: center;
840 } 832 }
841 } 833 }
diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss
index 0082378e4..96b3adf66 100644
--- a/client/src/sass/player/control-bar.scss
+++ b/client/src/sass/player/control-bar.scss
@@ -153,8 +153,25 @@
153 } 153 }
154 154
155 .vjs-live-control { 155 .vjs-live-control {
156 line-height: $control-bar-height; 156 padding: 5px 7px;
157 min-width: 4em; 157 border-radius: 3px;
158 height: fit-content;
159 margin: auto 10px;
160 font-weight: bold;
161 max-width: fit-content;
162 opacity: 1 !important;
163 line-height: normal;
164 position: relative;
165 top: -1px;
166
167 &.synced-with-live-edge {
168 background: #d7281c;
169 }
170
171 &:not(.synced-with-live-edge) {
172 cursor: pointer;
173 background: #80807f;
174 }
158 } 175 }
159 176
160 .vjs-peertube { 177 .vjs-peertube {
diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss
index 88f6efb6a..ee66a9db3 100644
--- a/client/src/sass/primeng-custom.scss
+++ b/client/src/sass/primeng-custom.scss
@@ -294,6 +294,7 @@ body .p-datepicker .p-datepicker-header .p-datepicker-title select:focus {
294body .p-datepicker table { 294body .p-datepicker table {
295 font-size: 14px; 295 font-size: 14px;
296 margin: 0.857em 0 0 0; 296 margin: 0.857em 0 0 0;
297 table-layout: fixed;
297} 298}
298body .p-datepicker table th { 299body .p-datepicker table th {
299 padding: 0.5em; 300 padding: 0.5em;
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts
index b0bdb2dd9..f09c86d14 100644
--- a/client/src/standalone/videos/shared/player-manager-options.ts
+++ b/client/src/standalone/videos/shared/player-manager-options.ts
@@ -38,6 +38,7 @@ export class PlayerManagerOptions {
38 private enableApi = false 38 private enableApi = false
39 private startTime: number | string = 0 39 private startTime: number | string = 0
40 private stopTime: number | string 40 private stopTime: number | string
41 private playbackRate: number | string
41 42
42 private title: boolean 43 private title: boolean
43 private warningTitle: boolean 44 private warningTitle: boolean
@@ -130,6 +131,7 @@ export class PlayerManagerOptions {
130 this.subtitle = getParamString(params, 'subtitle') 131 this.subtitle = getParamString(params, 'subtitle')
131 this.startTime = getParamString(params, 'start') 132 this.startTime = getParamString(params, 'start')
132 this.stopTime = getParamString(params, 'stop') 133 this.stopTime = getParamString(params, 'stop')
134 this.playbackRate = getParamString(params, 'playbackRate')
133 135
134 this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor') 136 this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
135 this.foregroundColor = getParamString(params, 'foregroundColor') 137 this.foregroundColor = getParamString(params, 'foregroundColor')
@@ -210,6 +212,8 @@ export class PlayerManagerOptions {
210 ? playlistTracker.getCurrentElement().stopTimestamp 212 ? playlistTracker.getCurrentElement().stopTimestamp
211 : this.stopTime, 213 : this.stopTime,
212 214
215 playbackRate: this.playbackRate,
216
213 videoCaptions, 217 videoCaptions,
214 inactivityTimeout: 2500, 218 inactivityTimeout: 2500,
215 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid), 219 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
diff --git a/client/yarn.lock b/client/yarn.lock
index b680bfdfb..1799df7b1 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -302,6 +302,11 @@
302 dependencies: 302 dependencies:
303 tslib "^2.3.0" 303 tslib "^2.3.0"
304 304
305"@arr/every@^1.0.0":
306 version "1.0.1"
307 resolved "https://registry.yarnpkg.com/@arr/every/-/every-1.0.1.tgz#22fe1f8e6355beca6c7c7bde965eb15cf994387b"
308 integrity sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==
309
305"@assemblyscript/loader@^0.10.1": 310"@assemblyscript/loader@^0.10.1":
306 version "0.10.1" 311 version "0.10.1"
307 resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.10.1.tgz#70e45678f06c72fa2e350e8553ec4a4d72b92e06" 312 resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.10.1.tgz#70e45678f06c72fa2e350e8553ec4a4d72b92e06"
@@ -1789,10 +1794,22 @@
1789 read-package-json-fast "^2.0.3" 1794 read-package-json-fast "^2.0.3"
1790 which "^2.0.2" 1795 which "^2.0.2"
1791 1796
1792"@peertube/p2p-media-loader-core@^1.0.13", "@peertube/p2p-media-loader-core@^1.0.8": 1797"@peertube/maildev@^1.2.0":
1793 version "1.0.13" 1798 version "1.2.0"
1794 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.13.tgz#36744a291b69c001b2562c1a93017979f8534ff8" 1799 resolved "https://registry.yarnpkg.com/@peertube/maildev/-/maildev-1.2.0.tgz#f25ee9fa6a45c0a6bc99c5392f63139eaa8eb088"
1795 integrity sha512-ArSAaeuxwwBAG0Xd3Gj0TzKObLfJFYzHz9+fREvmUf+GZQEG6qGwWmrdVWL6xjPiEuo6LdFeCOnHSQzAbj/ptg== 1800 integrity sha512-VGog0A2gk0P8UnP0ZjCoYQumELiqqQY5i+gt18avTC7NJNJLUxMRMI045NAVSDFVbqt2EJJPsbZf3LFjUWRtmw==
1801 dependencies:
1802 async "^3.1.0"
1803 commander "^8.3.0"
1804 mailparser-mit "^1.0.0"
1805 rimraf "^3.0.2"
1806 smtp-server "^3.9.0"
1807 wildstring "1.0.9"
1808
1809"@peertube/p2p-media-loader-core@^1.0.14":
1810 version "1.0.14"
1811 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.14.tgz#b4442dd343d6b30a51502e1240275eb98ef2c788"
1812 integrity sha512-tjQv1CNziNY+zYzcL1h4q6AA2WuBUZnBIeVyjWR/EsO1EEC1VMdvPsL02cqYLz9yvIxgycjeTsWCm6XDqNgXRw==
1796 dependencies: 1813 dependencies:
1797 bittorrent-tracker "^9.19.0" 1814 bittorrent-tracker "^9.19.0"
1798 debug "^4.3.4" 1815 debug "^4.3.4"
@@ -1800,12 +1817,12 @@
1800 sha.js "^2.4.11" 1817 sha.js "^2.4.11"
1801 simple-peer "^9.11.1" 1818 simple-peer "^9.11.1"
1802 1819
1803"@peertube/p2p-media-loader-hlsjs@^1.0.13": 1820"@peertube/p2p-media-loader-hlsjs@^1.0.14":
1804 version "1.0.13" 1821 version "1.0.14"
1805 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.13.tgz#5305e2008041d01850802544d1c49298f79dd67a" 1822 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.14.tgz#829629a57608b0e30f4b50bc98578e6bee9f8b9b"
1806 integrity sha512-2BO2oaRsSHEhLkgi2iw1r4n1Yqq1EnyoOgOZccPDqjmHUsZSV/wNrno8WYr6LsleudrHA26Imu57hVD1jDx7lg== 1823 integrity sha512-ySUVgUvAFXCE5E94xxjfywQ8xzk3jy9UGVkgi5Oqq+QeY7uG+o7CZ+LsQ/RjXgWBD70tEnyyfADHtL+9FCnwyQ==
1807 dependencies: 1824 dependencies:
1808 "@peertube/p2p-media-loader-core" "^1.0.8" 1825 "@peertube/p2p-media-loader-core" "^1.0.14"
1809 debug "^4.3.4" 1826 debug "^4.3.4"
1810 events "^3.3.0" 1827 events "^3.3.0"
1811 m3u8-parser "^4.7.1" 1828 m3u8-parser "^4.7.1"
@@ -1830,6 +1847,16 @@
1830 tokenizr "^1.6.4" 1847 tokenizr "^1.6.4"
1831 xmldom "^0.6.0" 1848 xmldom "^0.6.0"
1832 1849
1850"@polka/parse@^1.0.0-next.0":
1851 version "1.0.0-next.0"
1852 resolved "https://registry.yarnpkg.com/@polka/parse/-/parse-1.0.0-next.0.tgz#3551d792acdf4ad0b053072e57498cbe32e45a94"
1853 integrity sha512-zcPNrc3PNrRLSCQ7ca8XR7h18VxdPIXhn+yvrYMdUFCHM7mhXGSPw5xBdbcf/dQ1cI4uE8pDfmm5uU+HX+WfFg==
1854
1855"@polka/url@^0.5.0":
1856 version "0.5.0"
1857 resolved "https://registry.yarnpkg.com/@polka/url/-/url-0.5.0.tgz#b21510597fd601e5d7c95008b76bf0d254ebfd31"
1858 integrity sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw==
1859
1833"@polka/url@^1.0.0-next.20": 1860"@polka/url@^1.0.0-next.20":
1834 version "1.0.0-next.21" 1861 version "1.0.0-next.21"
1835 resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" 1862 resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
@@ -2021,6 +2048,11 @@
2021 dependencies: 2048 dependencies:
2022 "@types/node" "*" 2049 "@types/node" "*"
2023 2050
2051"@types/gitconfiglocal@^2.0.1":
2052 version "2.0.1"
2053 resolved "https://registry.yarnpkg.com/@types/gitconfiglocal/-/gitconfiglocal-2.0.1.tgz#c134f9fb03d71917afa35c14f3b82085520509a6"
2054 integrity sha512-AYC38la5dRwIfbrZhPNIvlGHlIbH+kdl2j8A37twoCQyhKPPoRPfVmoBZKajpLIfV7SMboU6MZ6w/RmZLH68IQ==
2055
2024"@types/html-minifier-terser@^6.0.0": 2056"@types/html-minifier-terser@^6.0.0":
2025 version "6.1.0" 2057 version "6.1.0"
2026 resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" 2058 resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
@@ -2127,9 +2159,9 @@
2127 "@types/lodash" "*" 2159 "@types/lodash" "*"
2128 2160
2129"@types/lodash@*": 2161"@types/lodash@*":
2130 version "4.14.189" 2162 version "4.14.191"
2131 resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.189.tgz#975ff8c38da5ae58b751127b19ad5e44b5b7f6d2" 2163 resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa"
2132 integrity sha512-kb9/98N6X8gyME9Cf7YaqIMvYGnBSWqEci6tiettE6iJWH1XdJz/PO8LB0GtLCG7x8dU3KWhZT+lA1a35127tA== 2164 integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==
2133 2165
2134"@types/magnet-uri@*": 2166"@types/magnet-uri@*":
2135 version "5.1.3" 2167 version "5.1.3"
@@ -2162,9 +2194,9 @@
2162 integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== 2194 integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
2163 2195
2164"@types/mocha@^10.0.0": 2196"@types/mocha@^10.0.0":
2165 version "10.0.0" 2197 version "10.0.1"
2166 resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.0.tgz#3d9018c575f0e3f7386c1de80ee66cc21fbb7a52" 2198 resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.1.tgz#2f4f65bb08bc368ac39c96da7b2f09140b26851b"
2167 integrity sha512-rADY+HtTOA52l9VZWtgQfn4p+UDVM2eDVkMZT1I6syp0YKxW2F9v+0pbRZLsvskhQv/vMb6ZfCay81GHbz5SHg== 2199 integrity sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==
2168 2200
2169"@types/mousetrap@^1.6.9": 2201"@types/mousetrap@^1.6.9":
2170 version "1.6.11" 2202 version "1.6.11"
@@ -2177,9 +2209,9 @@
2177 integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== 2209 integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
2178 2210
2179"@types/node@*", "@types/node@^18.0.0": 2211"@types/node@*", "@types/node@^18.0.0":
2180 version "18.11.9" 2212 version "18.11.18"
2181 resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4" 2213 resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f"
2182 integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== 2214 integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==
2183 2215
2184"@types/node@^17.0.42": 2216"@types/node@^17.0.42":
2185 version "17.0.45" 2217 version "17.0.45"
@@ -2380,9 +2412,9 @@
2380 integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== 2412 integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==
2381 2413
2382"@types/yargs@^17.0.8": 2414"@types/yargs@^17.0.8":
2383 version "17.0.13" 2415 version "17.0.19"
2384 resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.13.tgz#34cced675ca1b1d51fcf4d34c3c6f0fa142a5c76" 2416 resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.19.tgz#8dbecdc9ab48bee0cb74f6e3327de3fa0d0c98ae"
2385 integrity sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg== 2417 integrity sha512-cAx3qamwaYX9R0fzOIZAlFpo4A+1uBVCxqpKz9D26uTF4srRXaGTTsikQmaotCtNdbhzyUH7ft6p9ktz9s6UNQ==
2386 dependencies: 2418 dependencies:
2387 "@types/yargs-parser" "*" 2419 "@types/yargs-parser" "*"
2388 2420
@@ -2509,22 +2541,26 @@
2509 is-function "^1.0.1" 2541 is-function "^1.0.1"
2510 2542
2511"@wdio/browserstack-service@^7.25.2": 2543"@wdio/browserstack-service@^7.25.2":
2512 version "7.26.0" 2544 version "7.29.1"
2513 resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-7.26.0.tgz#d303c5998e565734bd7f5c23fc9b291a588b7c21" 2545 resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-7.29.1.tgz#46282aa07b7c11a51ebac0bff1f12f1badd6e264"
2514 integrity sha512-hRKmg4u/DRNZm1EJGaYESAH6GsCPCtBm15fP9ngm/HFUG084thFfrD8Tt09hO+KSNoK4tXl4k1ZHZ4akrOq9KA== 2546 integrity sha512-1+MoqlIXIjbh1oEOZcvtemij+Yz/CB6orZjeT3WCoA9oY8Ul8EeIHhfF7GxmE6u0OVofjmC+wfO5NlHYCKgL1w==
2515 dependencies: 2547 dependencies:
2516 "@types/node" "^18.0.0" 2548 "@types/gitconfiglocal" "^2.0.1"
2517 "@wdio/logger" "7.26.0" 2549 "@wdio/logger" "7.26.0"
2550 "@wdio/reporter" "7.25.4"
2518 "@wdio/types" "7.26.0" 2551 "@wdio/types" "7.26.0"
2519 browserstack-local "^1.4.5" 2552 browserstack-local "^1.4.5"
2520 form-data "^4.0.0" 2553 form-data "^4.0.0"
2554 git-repo-info "^2.1.1"
2555 gitconfiglocal "^2.1.0"
2521 got "^11.0.2" 2556 got "^11.0.2"
2522 webdriverio "7.26.0" 2557 uuid "^8.3.2"
2558 webdriverio "7.29.1"
2523 2559
2524"@wdio/cli@^7.25.2": 2560"@wdio/cli@^7.25.2":
2525 version "7.26.0" 2561 version "7.29.1"
2526 resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-7.26.0.tgz#20c690a5ede4a35cb2f84da9041c250a6013bc54" 2562 resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-7.29.1.tgz#1b47f5a45f21754d42be814dbae94ff723a6a1a2"
2527 integrity sha512-xG+ZIzPqzz/Tvhfrogd8oNvTXzzdE+cbkmTHjMGo1hnmnoAQPeAEcV/QqaX5CHFE9DjaguEeadqjcZikB5U2GQ== 2563 integrity sha512-dldHNYlnuFUG10TlENbeL41tujqgYD7S/9nzV1J/szBryCO6AIVz/QWn/AUv3zrsO2sn8TNF8BMEXRvLgCxyeg==
2528 dependencies: 2564 dependencies:
2529 "@types/ejs" "^3.0.5" 2565 "@types/ejs" "^3.0.5"
2530 "@types/fs-extra" "^9.0.4" 2566 "@types/fs-extra" "^9.0.4"
@@ -2536,7 +2572,7 @@
2536 "@types/recursive-readdir" "^2.2.0" 2572 "@types/recursive-readdir" "^2.2.0"
2537 "@wdio/config" "7.26.0" 2573 "@wdio/config" "7.26.0"
2538 "@wdio/logger" "7.26.0" 2574 "@wdio/logger" "7.26.0"
2539 "@wdio/protocols" "7.22.0" 2575 "@wdio/protocols" "7.27.0"
2540 "@wdio/types" "7.26.0" 2576 "@wdio/types" "7.26.0"
2541 "@wdio/utils" "7.26.0" 2577 "@wdio/utils" "7.26.0"
2542 async-exit-hook "^2.0.1" 2578 async-exit-hook "^2.0.1"
@@ -2551,7 +2587,7 @@
2551 lodash.union "^4.6.0" 2587 lodash.union "^4.6.0"
2552 mkdirp "^1.0.4" 2588 mkdirp "^1.0.4"
2553 recursive-readdir "^2.2.2" 2589 recursive-readdir "^2.2.2"
2554 webdriverio "7.26.0" 2590 webdriverio "7.29.1"
2555 yargs "^17.0.0" 2591 yargs "^17.0.0"
2556 yarn-install "^1.0.0" 2592 yarn-install "^1.0.0"
2557 2593
@@ -2567,14 +2603,14 @@
2567 glob "^8.0.3" 2603 glob "^8.0.3"
2568 2604
2569"@wdio/local-runner@^7.25.2": 2605"@wdio/local-runner@^7.25.2":
2570 version "7.26.0" 2606 version "7.29.1"
2571 resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-7.26.0.tgz#a056c6e9d73c7f48e54fe3f07ce573a90dae26ab" 2607 resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-7.29.1.tgz#f93a2953847b4271b59ba1b9635920e8046f0e55"
2572 integrity sha512-GdCP7Y8s8qvoctC0WaSGBSmTSbVw74WEJm6Y3n3DpoCI8ABFNkQlhFlqJH+taQDs3sRVEM65bHGcU4C4FOVWXQ== 2608 integrity sha512-4w9Dsp9/4+MEU8yG7M8ynsCqpSP6UbKqZ2M/gWpvkvy57rb3eS9evFdIFfRzuQmbsztG9qeAlGILwlZ4/oaopg==
2573 dependencies: 2609 dependencies:
2574 "@types/stream-buffers" "^3.0.3" 2610 "@types/stream-buffers" "^3.0.3"
2575 "@wdio/logger" "7.26.0" 2611 "@wdio/logger" "7.26.0"
2576 "@wdio/repl" "7.26.0" 2612 "@wdio/repl" "7.26.0"
2577 "@wdio/runner" "7.26.0" 2613 "@wdio/runner" "7.29.1"
2578 "@wdio/types" "7.26.0" 2614 "@wdio/types" "7.26.0"
2579 async-exit-hook "^2.0.1" 2615 async-exit-hook "^2.0.1"
2580 split2 "^4.0.0" 2616 split2 "^4.0.0"
@@ -2602,10 +2638,10 @@
2602 expect-webdriverio "^3.0.0" 2638 expect-webdriverio "^3.0.0"
2603 mocha "^10.0.0" 2639 mocha "^10.0.0"
2604 2640
2605"@wdio/protocols@7.22.0": 2641"@wdio/protocols@7.27.0":
2606 version "7.22.0" 2642 version "7.27.0"
2607 resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-7.22.0.tgz#d89faef687cb08981d734bbc5e5dffc6fb5a064c" 2643 resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-7.27.0.tgz#8e2663ec877dce7a5f76b021209c18dd0132e853"
2608 integrity sha512-8EXRR+Ymdwousm/VGtW3H1hwxZ/1g1H99A1lF0U4GuJ5cFWHCd0IVE5H31Z52i8ZruouW8jueMkGZPSo2IIUSQ== 2644 integrity sha512-hT/U22R5i3HhwPjkaKAG0yd59eaOaZB0eibRj2+esCImkb5Y6rg8FirrlYRxIGFVBl0+xZV0jKHzR5+o097nvg==
2609 2645
2610"@wdio/repl@7.26.0": 2646"@wdio/repl@7.26.0":
2611 version "7.26.0" 2647 version "7.26.0"
@@ -2614,10 +2650,26 @@
2614 dependencies: 2650 dependencies:
2615 "@wdio/utils" "7.26.0" 2651 "@wdio/utils" "7.26.0"
2616 2652
2617"@wdio/reporter@7.26.0": 2653"@wdio/reporter@7.25.4":
2618 version "7.26.0" 2654 version "7.25.4"
2619 resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.26.0.tgz#26c0e7114a4c1e7b29a79e4d178e5312e04d7934" 2655 resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.25.4.tgz#b6a69652dd0c4ec131255000af128eac403a18b9"
2620 integrity sha512-kEb7i1A4V4E1wJgdyvLsDbap4cEp1fPZslErGtbAbK+9HI8Lt/SlTZCiOpZbvhgzvawEqOV6UqxZT1RsL8wZWw== 2656 integrity sha512-M37qzEmF5qNffyZmRQGjDlrXqWW21EFvgW8wsv1b/NtfpZc0c0MoRpeh6BnvX1KcE4nCXfjXgSJPOqV4ZCzUEQ==
2657 dependencies:
2658 "@types/diff" "^5.0.0"
2659 "@types/node" "^18.0.0"
2660 "@types/object-inspect" "^1.8.0"
2661 "@types/supports-color" "^8.1.0"
2662 "@types/tmp" "^0.2.0"
2663 "@wdio/types" "7.25.4"
2664 diff "^5.0.0"
2665 fs-extra "^10.0.0"
2666 object-inspect "^1.10.3"
2667 supports-color "8.1.1"
2668
2669"@wdio/reporter@7.29.1":
2670 version "7.29.1"
2671 resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.29.1.tgz#7fc2e3b7aa3843172dcd97221c44257384cbbd27"
2672 integrity sha512-mpusCpbw7RxnJSDu9qa1qv5IfEMCh7377y1Typ4J2TlMy+78CQzGZ8coEXjBxLcqijTUwcyyoLNI5yRSvbDExw==
2621 dependencies: 2673 dependencies:
2622 "@types/diff" "^5.0.0" 2674 "@types/diff" "^5.0.0"
2623 "@types/node" "^18.0.0" 2675 "@types/node" "^18.0.0"
@@ -2630,10 +2682,10 @@
2630 object-inspect "^1.10.3" 2682 object-inspect "^1.10.3"
2631 supports-color "8.1.1" 2683 supports-color "8.1.1"
2632 2684
2633"@wdio/runner@7.26.0": 2685"@wdio/runner@7.29.1":
2634 version "7.26.0" 2686 version "7.29.1"
2635 resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-7.26.0.tgz#c0b2848dc885b655e8690d3e0381dfb0ad221af5" 2687 resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-7.29.1.tgz#9fd2fa6dd28b8b130a10d23452eb155e1e887576"
2636 integrity sha512-DhQiOs10oPeLlv7/R+997arPg5OY7iEgespGkn6r+kdx2o+awxa6PFegQrjJmRKUmNv3TTuKXHouP34TbR/8sw== 2688 integrity sha512-lJEk/HJ5IiuvAJws8zTx9XL5LJuoexvjWIZmOmFJ6Gv8qRpUx6b0n+JM7vhhbTeIqs4QLXOwTQUHlDDRldQlzQ==
2637 dependencies: 2689 dependencies:
2638 "@wdio/config" "7.26.0" 2690 "@wdio/config" "7.26.0"
2639 "@wdio/logger" "7.26.0" 2691 "@wdio/logger" "7.26.0"
@@ -2641,21 +2693,41 @@
2641 "@wdio/utils" "7.26.0" 2693 "@wdio/utils" "7.26.0"
2642 deepmerge "^4.0.0" 2694 deepmerge "^4.0.0"
2643 gaze "^1.1.2" 2695 gaze "^1.1.2"
2644 webdriver "7.26.0" 2696 webdriver "7.27.0"
2645 webdriverio "7.26.0" 2697 webdriverio "7.29.1"
2698
2699"@wdio/shared-store-service@^7.25.2":
2700 version "7.29.1"
2701 resolved "https://registry.yarnpkg.com/@wdio/shared-store-service/-/shared-store-service-7.29.1.tgz#c43a3dbc7d47c8334970bc173e963688977e8a79"
2702 integrity sha512-13VOxyz956DSs2wloQ8gtyEx42zjAuOg+N8/4tGk1p2igPzHB2qUiY/P0yi6zamxYGb6PKLIumIeUjitWHtyWA==
2703 dependencies:
2704 "@polka/parse" "^1.0.0-next.0"
2705 "@wdio/logger" "7.26.0"
2706 "@wdio/types" "7.26.0"
2707 got "^11.0.2"
2708 polka "^0.5.2"
2709 webdriverio "7.29.1"
2646 2710
2647"@wdio/spec-reporter@^7.25.1": 2711"@wdio/spec-reporter@^7.25.1":
2648 version "7.26.0" 2712 version "7.29.1"
2649 resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-7.26.0.tgz#13eaa5a0fd089684d4c1bcd8ac11dc8646afb5b7" 2713 resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-7.29.1.tgz#08e13c02ea0876672226d5a2c326dda7e1a66c8e"
2650 integrity sha512-oisyVWn+MRoq0We0qORoDHNk+iKr7CFG4+IE5GCRecR8cgP7dUjVXZcEbn6blgRpry4jOxsAl24frfaPDOsZVA== 2714 integrity sha512-bwSGM72QrDedqacY7Wq9Gn86VgRwIGPYzZtcaD7aDnvppCuV8Z/31Wpdfen+CzUk2+whXjXKe66ohPyl9TG5+w==
2651 dependencies: 2715 dependencies:
2652 "@types/easy-table" "^1.2.0" 2716 "@types/easy-table" "^1.2.0"
2653 "@wdio/reporter" "7.26.0" 2717 "@wdio/reporter" "7.29.1"
2654 "@wdio/types" "7.26.0" 2718 "@wdio/types" "7.26.0"
2655 chalk "^4.0.0" 2719 chalk "^4.0.0"
2656 easy-table "^1.1.1" 2720 easy-table "^1.1.1"
2657 pretty-ms "^7.0.0" 2721 pretty-ms "^7.0.0"
2658 2722
2723"@wdio/types@7.25.4":
2724 version "7.25.4"
2725 resolved "https://registry.yarnpkg.com/@wdio/types/-/types-7.25.4.tgz#6f8f028e3108dc880de5068264695f1572e65352"
2726 integrity sha512-muvNmq48QZCvocctnbe0URq2FjJjUPIG4iLoeMmyF0AQgdbjaUkMkw3BHYNHVTbSOU9WMsr2z8alhj/I2H6NRQ==
2727 dependencies:
2728 "@types/node" "^18.0.0"
2729 got "^11.8.1"
2730
2659"@wdio/types@7.26.0": 2731"@wdio/types@7.26.0":
2660 version "7.26.0" 2732 version "7.26.0"
2661 resolved "https://registry.yarnpkg.com/@wdio/types/-/types-7.26.0.tgz#70bc879c5dbe316a0eebbac4a46f0f66430b1d84" 2733 resolved "https://registry.yarnpkg.com/@wdio/types/-/types-7.26.0.tgz#70bc879c5dbe316a0eebbac4a46f0f66430b1d84"
@@ -2882,6 +2954,11 @@ addr-to-ip-port@^1.0.1, addr-to-ip-port@^1.5.4:
2882 resolved "https://registry.yarnpkg.com/addr-to-ip-port/-/addr-to-ip-port-1.5.4.tgz#9542b1c6219fdb8c9ce6cc72c14ee880ab7ddd88" 2954 resolved "https://registry.yarnpkg.com/addr-to-ip-port/-/addr-to-ip-port-1.5.4.tgz#9542b1c6219fdb8c9ce6cc72c14ee880ab7ddd88"
2883 integrity sha512-ByxmJgv8vjmDcl3IDToxL2yrWFrRtFpZAToY0f46XFXl8zS081t7El5MXIodwm7RC6DhHBRoOSMLFSPKCtHukg== 2955 integrity sha512-ByxmJgv8vjmDcl3IDToxL2yrWFrRtFpZAToY0f46XFXl8zS081t7El5MXIodwm7RC6DhHBRoOSMLFSPKCtHukg==
2884 2956
2957addressparser@^1.0.1:
2958 version "1.0.1"
2959 resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746"
2960 integrity sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==
2961
2885adjust-sourcemap-loader@^4.0.0: 2962adjust-sourcemap-loader@^4.0.0:
2886 version "4.0.0" 2963 version "4.0.0"
2887 resolved "https://registry.yarnpkg.com/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz#fc4a0fd080f7d10471f30a7320f25560ade28c99" 2964 resolved "https://registry.yarnpkg.com/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz#fc4a0fd080f7d10471f30a7320f25560ade28c99"
@@ -3057,9 +3134,9 @@ ansi-styles@^5.0.0:
3057 integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== 3134 integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
3058 3135
3059anymatch@~3.1.2: 3136anymatch@~3.1.2:
3060 version "3.1.2" 3137 version "3.1.3"
3061 resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" 3138 resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
3062 integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== 3139 integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
3063 dependencies: 3140 dependencies:
3064 normalize-path "^3.0.0" 3141 normalize-path "^3.0.0"
3065 picomatch "^2.0.4" 3142 picomatch "^2.0.4"
@@ -3188,7 +3265,7 @@ async-exit-hook@^2.0.1:
3188 resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3" 3265 resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3"
3189 integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw== 3266 integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==
3190 3267
3191async@^3.2.3: 3268async@^3.1.0, async@^3.2.3:
3192 version "3.2.4" 3269 version "3.2.4"
3193 resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" 3270 resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
3194 integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== 3271 integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==
@@ -3327,6 +3404,11 @@ balanced-match@^2.0.0:
3327 resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9" 3404 resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9"
3328 integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== 3405 integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==
3329 3406
3407base32.js@0.1.0:
3408 version "0.1.0"
3409 resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.1.0.tgz#b582dec693c2f11e893cf064ee6ac5b6131a2202"
3410 integrity sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==
3411
3330base64-js@^1.2.0, base64-js@^1.3.1: 3412base64-js@^1.2.0, base64-js@^1.3.1:
3331 version "1.5.1" 3413 version "1.5.1"
3332 resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" 3414 resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -3933,9 +4015,9 @@ chunk-store-stream@^4.3.0:
3933 readable-stream "^3.6.0" 4015 readable-stream "^3.6.0"
3934 4016
3935ci-info@^3.2.0: 4017ci-info@^3.2.0:
3936 version "3.6.1" 4018 version "3.7.1"
3937 resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.6.1.tgz#7594f1c95cb7fdfddee7af95a13af7dbc67afdcf" 4019 resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.7.1.tgz#708a6cdae38915d597afdf3b145f2f8e1ff55f3f"
3938 integrity sha512-up5ggbaDqOqJ4UqLKZ2naVkyqSJQgJi5lwD6b6mM748ysrghDBX0bx/qJTUHzw7zu6Mq4gycviSF5hJnwceD8w== 4020 integrity sha512-4jYS4MOAaCIStSRwiuxc4B8MYhIe676yO1sYGzARnjXkWpmzZMMYxY6zu8WYWDhSuth5zhrQ1rhNSibyyvv4/w==
3939 4021
3940clean-css@5.2.0: 4022clean-css@5.2.0:
3941 version "5.2.0" 4023 version "5.2.0"
@@ -4504,16 +4586,18 @@ decompress-response@^6.0.0:
4504 mimic-response "^3.1.0" 4586 mimic-response "^3.1.0"
4505 4587
4506deep-equal@^2.0.5: 4588deep-equal@^2.0.5:
4507 version "2.1.0" 4589 version "2.2.0"
4508 resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.1.0.tgz#5ba60402cf44ab92c2c07f3f3312c3d857a0e1dd" 4590 resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.0.tgz#5caeace9c781028b9ff459f33b779346637c43e6"
4509 integrity sha512-2pxgvWu3Alv1PoWEyVg7HS8YhGlUFUV7N5oOvfL6d+7xAmLSemMwv/c8Zv/i9KFzxV5Kt5CAvQc70fLwVuf4UA== 4591 integrity sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==
4510 dependencies: 4592 dependencies:
4511 call-bind "^1.0.2" 4593 call-bind "^1.0.2"
4512 es-get-iterator "^1.1.2" 4594 es-get-iterator "^1.1.2"
4513 get-intrinsic "^1.1.3" 4595 get-intrinsic "^1.1.3"
4514 is-arguments "^1.1.1" 4596 is-arguments "^1.1.1"
4597 is-array-buffer "^3.0.1"
4515 is-date-object "^1.0.5" 4598 is-date-object "^1.0.5"
4516 is-regex "^1.1.4" 4599 is-regex "^1.1.4"
4600 is-shared-array-buffer "^1.0.2"
4517 isarray "^2.0.5" 4601 isarray "^2.0.5"
4518 object-is "^1.1.5" 4602 object-is "^1.1.5"
4519 object-keys "^1.1.1" 4603 object-keys "^1.1.1"
@@ -4522,7 +4606,7 @@ deep-equal@^2.0.5:
4522 side-channel "^1.0.4" 4606 side-channel "^1.0.4"
4523 which-boxed-primitive "^1.0.2" 4607 which-boxed-primitive "^1.0.2"
4524 which-collection "^1.0.1" 4608 which-collection "^1.0.1"
4525 which-typed-array "^1.1.8" 4609 which-typed-array "^1.1.9"
4526 4610
4527deep-is@^0.1.3: 4611deep-is@^0.1.3:
4528 version "0.1.4" 4612 version "0.1.4"
@@ -4606,21 +4690,21 @@ devtools-protocol@0.0.981744:
4606 resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf" 4690 resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
4607 integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg== 4691 integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
4608 4692
4609devtools-protocol@^0.0.1069585: 4693devtools-protocol@^0.0.1085790:
4610 version "0.0.1069585" 4694 version "0.0.1085790"
4611 resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1069585.tgz#c9a9f330462aabf054d581f254b13774297b84f2" 4695 resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1085790.tgz#315e4700eb960cf111cc908b9be2caca2257cb13"
4612 integrity sha512-sHmkZB6immWQWU4Wx3ogXwxjQUvQc92MmUDL52+q1z2hQmvpOcvDmbsjwX7QZOPTA32dMV7fgT6zUytcpPzy4A== 4696 integrity sha512-f5kfwdOTxPqX5v8ZfAAl9xBgoEVazBYtIONDWIRqYbb7yjOIcnk6vpzCgBCQvav5AuBRLzyUGG0V74OAx93LoA==
4613 4697
4614devtools@7.26.0: 4698devtools@7.28.1:
4615 version "7.26.0" 4699 version "7.28.1"
4616 resolved "https://registry.yarnpkg.com/devtools/-/devtools-7.26.0.tgz#3d568aea2238d190ad0cd71c00483c07c707124a" 4700 resolved "https://registry.yarnpkg.com/devtools/-/devtools-7.28.1.tgz#9699e0ca41c9a3adfa351d8afac2928f8e1d381c"
4617 integrity sha512-+8HNbNpzgo4Sn+WcrvXuwsHW9XPJfLo4bs9lgs6DPJHIIDXYJXQGsd7940wMX0Rp0D2vHXA4ibK0oTI5rogM3Q== 4701 integrity sha512-sDoszzrXDMLiBQqsg9A5gDqDBwhH4sjYzJIW15lQinB8qgNs0y4o1zdfNlqiKs4HstCA2uFixQeibbDCyMa7hQ==
4618 dependencies: 4702 dependencies:
4619 "@types/node" "^18.0.0" 4703 "@types/node" "^18.0.0"
4620 "@types/ua-parser-js" "^0.7.33" 4704 "@types/ua-parser-js" "^0.7.33"
4621 "@wdio/config" "7.26.0" 4705 "@wdio/config" "7.26.0"
4622 "@wdio/logger" "7.26.0" 4706 "@wdio/logger" "7.26.0"
4623 "@wdio/protocols" "7.22.0" 4707 "@wdio/protocols" "7.27.0"
4624 "@wdio/types" "7.26.0" 4708 "@wdio/types" "7.26.0"
4625 "@wdio/utils" "7.26.0" 4709 "@wdio/utils" "7.26.0"
4626 chrome-launcher "^0.15.0" 4710 chrome-launcher "^0.15.0"
@@ -4923,18 +5007,19 @@ es-abstract@^1.19.0, es-abstract@^1.20.4:
4923 unbox-primitive "^1.0.2" 5007 unbox-primitive "^1.0.2"
4924 5008
4925es-get-iterator@^1.1.2: 5009es-get-iterator@^1.1.2:
4926 version "1.1.2" 5010 version "1.1.3"
4927 resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.2.tgz#9234c54aba713486d7ebde0220864af5e2b283f7" 5011 resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6"
4928 integrity sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ== 5012 integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==
4929 dependencies: 5013 dependencies:
4930 call-bind "^1.0.2" 5014 call-bind "^1.0.2"
4931 get-intrinsic "^1.1.0" 5015 get-intrinsic "^1.1.3"
4932 has-symbols "^1.0.1" 5016 has-symbols "^1.0.3"
4933 is-arguments "^1.1.0" 5017 is-arguments "^1.1.1"
4934 is-map "^2.0.2" 5018 is-map "^2.0.2"
4935 is-set "^2.0.2" 5019 is-set "^2.0.2"
4936 is-string "^1.0.5" 5020 is-string "^1.0.7"
4937 isarray "^2.0.5" 5021 isarray "^2.0.5"
5022 stop-iteration-iterator "^1.0.0"
4938 5023
4939es-module-lexer@^0.9.0: 5024es-module-lexer@^0.9.0:
4940 version "0.9.3" 5025 version "0.9.3"
@@ -5104,7 +5189,7 @@ escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0:
5104 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" 5189 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
5105 integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== 5190 integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
5106 5191
5107escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: 5192escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5, escape-string-regexp@~1.0.5:
5108 version "1.0.5" 5193 version "1.0.5"
5109 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 5194 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
5110 integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== 5195 integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
@@ -5404,7 +5489,7 @@ express@^4.17.3:
5404 utils-merge "1.0.1" 5489 utils-merge "1.0.1"
5405 vary "~1.1.2" 5490 vary "~1.1.2"
5406 5491
5407extend@~3.0.2: 5492extend@~3.0.0, extend@~3.0.2:
5408 version "3.0.2" 5493 version "3.0.2"
5409 resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" 5494 resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
5410 integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== 5495 integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
@@ -5856,6 +5941,18 @@ getpass@^0.1.1:
5856 dependencies: 5941 dependencies:
5857 assert-plus "^1.0.0" 5942 assert-plus "^1.0.0"
5858 5943
5944git-repo-info@^2.1.1:
5945 version "2.1.1"
5946 resolved "https://registry.yarnpkg.com/git-repo-info/-/git-repo-info-2.1.1.tgz#220ffed8cbae74ef8a80e3052f2ccb5179aed058"
5947 integrity sha512-8aCohiDo4jwjOwma4FmYFd3i97urZulL8XL24nIPxuE+GZnfsAyy/g2Shqx6OjUiFKUXZM+Yy+KHnOmmA3FVcg==
5948
5949gitconfiglocal@^2.1.0:
5950 version "2.1.0"
5951 resolved "https://registry.yarnpkg.com/gitconfiglocal/-/gitconfiglocal-2.1.0.tgz#07c28685c55cc5338b27b5acbcfe34aeb92e43d1"
5952 integrity sha512-qoerOEliJn3z+Zyn1HW2F6eoYJqKwS6MgC9cztTLUB/xLWX8gD/6T60pKn4+t/d6tP7JlybI7Z3z+I572CR/Vg==
5953 dependencies:
5954 ini "^1.3.2"
5955
5859glob-parent@^5.1.2, glob-parent@~5.1.2: 5956glob-parent@^5.1.2, glob-parent@~5.1.2:
5860 version "5.1.2" 5957 version "5.1.2"
5861 resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 5958 resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
@@ -5887,7 +5984,7 @@ glob@7.2.0:
5887 once "^1.3.0" 5984 once "^1.3.0"
5888 path-is-absolute "^1.0.0" 5985 path-is-absolute "^1.0.0"
5889 5986
5890glob@8.0.3, glob@^8.0.1, glob@^8.0.3: 5987glob@8.0.3, glob@^8.0.1:
5891 version "8.0.3" 5988 version "8.0.3"
5892 resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e" 5989 resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e"
5893 integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ== 5990 integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==
@@ -5910,6 +6007,17 @@ glob@^7.0.5, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
5910 once "^1.3.0" 6007 once "^1.3.0"
5911 path-is-absolute "^1.0.0" 6008 path-is-absolute "^1.0.0"
5912 6009
6010glob@^8.0.3:
6011 version "8.1.0"
6012 resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e"
6013 integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==
6014 dependencies:
6015 fs.realpath "^1.0.0"
6016 inflight "^1.0.4"
6017 inherits "2"
6018 minimatch "^5.0.1"
6019 once "^1.3.0"
6020
5913glob@~7.1.1: 6021glob@~7.1.1:
5914 version "7.1.7" 6022 version "7.1.7"
5915 resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" 6023 resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
@@ -6002,7 +6110,7 @@ gopd@^1.0.1:
6002 dependencies: 6110 dependencies:
6003 get-intrinsic "^1.1.3" 6111 get-intrinsic "^1.1.3"
6004 6112
6005got@11.8.5, got@^11.0.2, got@^11.8.1: 6113got@11.8.5:
6006 version "11.8.5" 6114 version "11.8.5"
6007 resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046" 6115 resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046"
6008 integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ== 6116 integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==
@@ -6019,6 +6127,23 @@ got@11.8.5, got@^11.0.2, got@^11.8.1:
6019 p-cancelable "^2.0.0" 6127 p-cancelable "^2.0.0"
6020 responselike "^2.0.0" 6128 responselike "^2.0.0"
6021 6129
6130got@^11.0.2, got@^11.8.1:
6131 version "11.8.6"
6132 resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a"
6133 integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==
6134 dependencies:
6135 "@sindresorhus/is" "^4.0.0"
6136 "@szmarczak/http-timer" "^4.0.5"
6137 "@types/cacheable-request" "^6.0.1"
6138 "@types/responselike" "^1.0.0"
6139 cacheable-lookup "^5.0.3"
6140 cacheable-request "^7.0.2"
6141 decompress-response "^6.0.0"
6142 http2-wrapper "^1.0.0-beta.5.2"
6143 lowercase-keys "^2.0.0"
6144 p-cancelable "^2.0.0"
6145 responselike "^2.0.0"
6146
6022graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: 6147graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9:
6023 version "4.2.10" 6148 version "4.2.10"
6024 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" 6149 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
@@ -6093,7 +6218,7 @@ has-property-descriptors@^1.0.0:
6093 dependencies: 6218 dependencies:
6094 get-intrinsic "^1.1.1" 6219 get-intrinsic "^1.1.1"
6095 6220
6096has-symbols@^1.0.1, has-symbols@^1.0.2, has-symbols@^1.0.3: 6221has-symbols@^1.0.2, has-symbols@^1.0.3:
6097 version "1.0.3" 6222 version "1.0.3"
6098 resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" 6223 resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
6099 integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== 6224 integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
@@ -6347,7 +6472,7 @@ humanize-ms@^1.2.1:
6347 dependencies: 6472 dependencies:
6348 ms "^2.0.0" 6473 ms "^2.0.0"
6349 6474
6350iconv-lite@0.4.24, iconv-lite@^0.4.24: 6475iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.24:
6351 version "0.4.24" 6476 version "0.4.24"
6352 resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" 6477 resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
6353 integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== 6478 integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -6469,7 +6594,7 @@ ini@3.0.0:
6469 resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.0.tgz#2f6de95006923aa75feed8894f5686165adc08f1" 6594 resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.0.tgz#2f6de95006923aa75feed8894f5686165adc08f1"
6470 integrity sha512-TxYQaeNW/N8ymDvwAxPyRbhMBtnEwuvaTYpOQkFx1nSeusgezHniEc/l35Vo4iCq/mMiTJbpD7oYxN98hFlfmw== 6595 integrity sha512-TxYQaeNW/N8ymDvwAxPyRbhMBtnEwuvaTYpOQkFx1nSeusgezHniEc/l35Vo4iCq/mMiTJbpD7oYxN98hFlfmw==
6471 6596
6472ini@^1.3.5: 6597ini@^1.3.2, ini@^1.3.5:
6473 version "1.3.8" 6598 version "1.3.8"
6474 resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" 6599 resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
6475 integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== 6600 integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
@@ -6504,6 +6629,15 @@ internal-slot@^1.0.3:
6504 has "^1.0.3" 6629 has "^1.0.3"
6505 side-channel "^1.0.4" 6630 side-channel "^1.0.4"
6506 6631
6632internal-slot@^1.0.4:
6633 version "1.0.4"
6634 resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.4.tgz#8551e7baf74a7a6ba5f749cfb16aa60722f0d6f3"
6635 integrity sha512-tA8URYccNzMo94s5MQZgH8NB/XTa6HsOo0MLfXTKKEnHVVdegzaQoFZ7Jp44bdvLvY2waT5dc+j5ICEswhi7UQ==
6636 dependencies:
6637 get-intrinsic "^1.1.3"
6638 has "^1.0.3"
6639 side-channel "^1.0.4"
6640
6507interpret@^2.2.0: 6641interpret@^2.2.0:
6508 version "2.2.0" 6642 version "2.2.0"
6509 resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" 6643 resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
@@ -6556,7 +6690,12 @@ ipaddr.js@1.9.1:
6556 resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" 6690 resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0"
6557 integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== 6691 integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==
6558 6692
6559is-arguments@^1.1.0, is-arguments@^1.1.1: 6693ipv6-normalize@1.0.1:
6694 version "1.0.1"
6695 resolved "https://registry.yarnpkg.com/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz#1b3258290d365fa83239e89907dde4592e7620a8"
6696 integrity sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==
6697
6698is-arguments@^1.1.1:
6560 version "1.1.1" 6699 version "1.1.1"
6561 resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" 6700 resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
6562 integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== 6701 integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==
@@ -6564,6 +6703,15 @@ is-arguments@^1.1.0, is-arguments@^1.1.1:
6564 call-bind "^1.0.2" 6703 call-bind "^1.0.2"
6565 has-tostringtag "^1.0.0" 6704 has-tostringtag "^1.0.0"
6566 6705
6706is-array-buffer@^3.0.1:
6707 version "3.0.1"
6708 resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.1.tgz#deb1db4fcae48308d54ef2442706c0393997052a"
6709 integrity sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ==
6710 dependencies:
6711 call-bind "^1.0.2"
6712 get-intrinsic "^1.1.3"
6713 is-typed-array "^1.1.10"
6714
6567is-arrayish@^0.2.1: 6715is-arrayish@^0.2.1:
6568 version "0.2.1" 6716 version "0.2.1"
6569 resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" 6717 resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -7508,6 +7656,16 @@ magnet-uri@^6.2.0:
7508 bep53-range "^1.1.0" 7656 bep53-range "^1.1.0"
7509 thirty-two "^1.0.2" 7657 thirty-two "^1.0.2"
7510 7658
7659mailparser-mit@^1.0.0:
7660 version "1.0.0"
7661 resolved "https://registry.yarnpkg.com/mailparser-mit/-/mailparser-mit-1.0.0.tgz#19df8436c2a02e1d34a03ec518a2eb065e0a94a4"
7662 integrity sha512-sckRITNb3VCT1sQ275g47MAN786pQ5lU20bLY5f794dF/ARGzuVATQ64gO13FOw8jayjFT10e5ttsripKGGXcw==
7663 dependencies:
7664 addressparser "^1.0.1"
7665 iconv-lite "~0.4.24"
7666 mime "^1.6.0"
7667 uue "^3.1.0"
7668
7511make-dir@^2.1.0: 7669make-dir@^2.1.0:
7512 version "2.1.0" 7670 version "2.1.0"
7513 resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" 7671 resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@@ -7576,6 +7734,13 @@ marky@^1.2.2:
7576 resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0" 7734 resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
7577 integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q== 7735 integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
7578 7736
7737matchit@^1.0.0:
7738 version "1.1.0"
7739 resolved "https://registry.yarnpkg.com/matchit/-/matchit-1.1.0.tgz#c4ccf17d9c824cc1301edbcffde9b75a61d10a7c"
7740 integrity sha512-+nGYoOlfHmxe5BW5tE0EMJppXEwdSf8uBA1GTZC7Q77kbT35+VKLYJMzVNWCHSsga1ps1tPYFtFyvxvKzWVmMA==
7741 dependencies:
7742 "@arr/every" "^1.0.0"
7743
7579mathml-tag-names@^2.1.3: 7744mathml-tag-names@^2.1.3:
7580 version "2.1.3" 7745 version "2.1.3"
7581 resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" 7746 resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3"
@@ -7679,7 +7844,7 @@ mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17,
7679 dependencies: 7844 dependencies:
7680 mime-db "1.52.0" 7845 mime-db "1.52.0"
7681 7846
7682mime@1.6.0, mime@^1.4.1: 7847mime@1.6.0, mime@^1.4.1, mime@^1.6.0:
7683 version "1.6.0" 7848 version "1.6.0"
7684 resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 7849 resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
7685 integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 7850 integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
@@ -7740,7 +7905,7 @@ minimatch@5.0.1:
7740 dependencies: 7905 dependencies:
7741 brace-expansion "^2.0.1" 7906 brace-expansion "^2.0.1"
7742 7907
7743minimatch@5.1.0, minimatch@^5.0.0, minimatch@^5.0.1, minimatch@^5.1.0: 7908minimatch@5.1.0:
7744 version "5.1.0" 7909 version "5.1.0"
7745 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7" 7910 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7"
7746 integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg== 7911 integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==
@@ -7754,6 +7919,13 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
7754 dependencies: 7919 dependencies:
7755 brace-expansion "^1.1.7" 7920 brace-expansion "^1.1.7"
7756 7921
7922minimatch@^5.0.0, minimatch@^5.0.1, minimatch@^5.1.0:
7923 version "5.1.6"
7924 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
7925 integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
7926 dependencies:
7927 brace-expansion "^2.0.1"
7928
7757minimatch@~3.0.2: 7929minimatch@~3.0.2:
7758 version "3.0.8" 7930 version "3.0.8"
7759 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1" 7931 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1"
@@ -7848,9 +8020,9 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
7848 integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== 8020 integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
7849 8021
7850mocha@^10.0.0: 8022mocha@^10.0.0:
7851 version "10.1.0" 8023 version "10.2.0"
7852 resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.1.0.tgz#dbf1114b7c3f9d0ca5de3133906aea3dfc89ef7a" 8024 resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8"
7853 integrity sha512-vUF7IYxEoN7XhQpFLxQAEMtE4W91acW4B6En9l97MwE9stL1A9gusXfoHZCLVHDUJ/7V5+lbCM6yMqzo5vNymg== 8025 integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==
7854 dependencies: 8026 dependencies:
7855 ansi-colors "4.1.1" 8027 ansi-colors "4.1.1"
7856 browser-stdout "1.3.1" 8028 browser-stdout "1.3.1"
@@ -8080,6 +8252,11 @@ node-releases@^2.0.6:
8080 resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" 8252 resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
8081 integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== 8253 integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
8082 8254
8255nodemailer@6.7.3:
8256 version "6.7.3"
8257 resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.3.tgz#b73f9a81b9c8fa8acb4ea14b608f5e725ea8e018"
8258 integrity sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==
8259
8083nopt@^6.0.0: 8260nopt@^6.0.0:
8084 version "6.0.0" 8261 version "6.0.0"
8085 resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d" 8262 resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d"
@@ -8267,7 +8444,12 @@ oauth-sign@~0.9.0:
8267 resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" 8444 resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
8268 integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== 8445 integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
8269 8446
8270object-inspect@^1.10.3, object-inspect@^1.12.2, object-inspect@^1.9.0: 8447object-inspect@^1.10.3, object-inspect@^1.9.0:
8448 version "1.12.3"
8449 resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
8450 integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
8451
8452object-inspect@^1.12.2:
8271 version "1.12.2" 8453 version "1.12.2"
8272 resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" 8454 resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
8273 integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== 8455 integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
@@ -8775,6 +8957,14 @@ pngjs@^5.0.0:
8775 resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" 8957 resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
8776 integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== 8958 integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
8777 8959
8960polka@^0.5.2:
8961 version "0.5.2"
8962 resolved "https://registry.yarnpkg.com/polka/-/polka-0.5.2.tgz#588bee0c5806dbc6c64958de3a1251860e9f2e26"
8963 integrity sha512-FVg3vDmCqP80tOrs+OeNlgXYmFppTXdjD5E7I4ET1NjvtNmQrb1/mJibybKkb/d4NA7YWAr1ojxuhpL3FHqdlw==
8964 dependencies:
8965 "@polka/url" "^0.5.0"
8966 trouter "^2.0.1"
8967
8778postcss-attribute-case-insensitive@^5.0.2: 8968postcss-attribute-case-insensitive@^5.0.2:
8779 version "5.0.2" 8969 version "5.0.2"
8780 resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz#03d761b24afc04c09e757e92ff53716ae8ea2741" 8970 resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz#03d761b24afc04c09e757e92ff53716ae8ea2741"
@@ -9285,9 +9475,9 @@ qs@~6.5.2:
9285 integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== 9475 integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==
9286 9476
9287query-selector-shadow-dom@^1.0.0: 9477query-selector-shadow-dom@^1.0.0:
9288 version "1.0.0" 9478 version "1.0.1"
9289 resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.0.tgz#8fa7459a4620f094457640e74e953a9dbe61a38e" 9479 resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz#1c7b0058eff4881ac44f45d8f84ede32e9a2f349"
9290 integrity sha512-bK0/0cCI+R8ZmOF1QjT7HupDUYCxbf/9TJgAmSXQxZpftXmTAeil9DRoCnTDkWbvOyZzhcMBwKpptWcdkGFIMg== 9480 integrity sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==
9291 9481
9292querystring@0.2.0: 9482querystring@0.2.0:
9293 version "0.2.0" 9483 version "0.2.0"
@@ -9736,9 +9926,9 @@ responselike@^2.0.0:
9736 lowercase-keys "^2.0.0" 9926 lowercase-keys "^2.0.0"
9737 9927
9738resq@^1.9.1: 9928resq@^1.9.1:
9739 version "1.10.2" 9929 version "1.11.0"
9740 resolved "https://registry.yarnpkg.com/resq/-/resq-1.10.2.tgz#cedf4f20d53f6e574b1e12afbda446ad9576c193" 9930 resolved "https://registry.yarnpkg.com/resq/-/resq-1.11.0.tgz#edec8c58be9af800fd628118c0ca8815283de196"
9741 integrity sha512-HmgVS3j+FLrEDBTDYysPdPVF9/hioDMJ/otOiQDKqk77YfZeeLOj0qi34yObumcud1gBpk+wpBTEg4kMicD++A== 9931 integrity sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==
9742 dependencies: 9932 dependencies:
9743 fast-deep-equal "^2.0.1" 9933 fast-deep-equal "^2.0.1"
9744 9934
@@ -9835,13 +10025,20 @@ rxjs@6.6.7:
9835 dependencies: 10025 dependencies:
9836 tslib "^1.9.0" 10026 tslib "^1.9.0"
9837 10027
9838rxjs@^7.3.0, rxjs@^7.4.0, rxjs@^7.5.5: 10028rxjs@^7.3.0, rxjs@^7.4.0:
9839 version "7.5.7" 10029 version "7.5.7"
9840 resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39" 10030 resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39"
9841 integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA== 10031 integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==
9842 dependencies: 10032 dependencies:
9843 tslib "^2.1.0" 10033 tslib "^2.1.0"
9844 10034
10035rxjs@^7.5.5:
10036 version "7.8.0"
10037 resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4"
10038 integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==
10039 dependencies:
10040 tslib "^2.1.0"
10041
9845safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: 10042safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
9846 version "5.1.2" 10043 version "5.1.2"
9847 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" 10044 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@ -10191,6 +10388,15 @@ smart-buffer@^4.2.0:
10191 resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" 10388 resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
10192 integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== 10389 integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
10193 10390
10391smtp-server@^3.9.0:
10392 version "3.11.0"
10393 resolved "https://registry.yarnpkg.com/smtp-server/-/smtp-server-3.11.0.tgz#8820c191124fab37a8f16c8325a7f1fd38092c4f"
10394 integrity sha512-j/W6mEKeMNKuiM9oCAAjm87agPEN1O3IU4cFLT4ZOCyyq3UXN7HiIXF+q7izxJcYSar15B/JaSxcijoPCR8Tag==
10395 dependencies:
10396 base32.js "0.1.0"
10397 ipv6-normalize "1.0.1"
10398 nodemailer "6.7.3"
10399
10194socket.io-client@^4.5.4: 10400socket.io-client@^4.5.4:
10195 version "4.5.4" 10401 version "4.5.4"
10196 resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.5.4.tgz#d3cde8a06a6250041ba7390f08d2468ccebc5ac9" 10402 resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.5.4.tgz#d3cde8a06a6250041ba7390f08d2468ccebc5ac9"
@@ -10420,6 +10626,13 @@ statuses@2.0.1:
10420 resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" 10626 resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
10421 integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== 10627 integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
10422 10628
10629stop-iteration-iterator@^1.0.0:
10630 version "1.0.0"
10631 resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4"
10632 integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==
10633 dependencies:
10634 internal-slot "^1.0.4"
10635
10423stream-browserify@^3.0.0: 10636stream-browserify@^3.0.0:
10424 version "3.0.0" 10637 version "3.0.0"
10425 resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" 10638 resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f"
@@ -10980,6 +11193,13 @@ trim-newlines@^3.0.0:
10980 resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" 11193 resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
10981 integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw== 11194 integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==
10982 11195
11196trouter@^2.0.1:
11197 version "2.0.1"
11198 resolved "https://registry.yarnpkg.com/trouter/-/trouter-2.0.1.tgz#2726a5f8558e090d24c3a393f09eaab1df232df6"
11199 integrity sha512-kr8SKKw94OI+xTGOkfsvwZQ8mWoikZDd2n8XZHjJVZUARZT+4/VV6cacRS6CLsH9bNm+HFIPU1Zx4CnNnb4qlQ==
11200 dependencies:
11201 matchit "^1.0.0"
11202
10983ts-loader@^9.3.0: 11203ts-loader@^9.3.0:
10984 version "9.4.1" 11204 version "9.4.1"
10985 resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.4.1.tgz#b6f3d82db0eac5a8295994f8cb5e4940ff6b1060" 11205 resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.4.1.tgz#b6f3d82db0eac5a8295994f8cb5e4940ff6b1060"
@@ -11280,6 +11500,14 @@ utp-native@^2.5.3:
11280 timeout-refresh "^1.0.0" 11500 timeout-refresh "^1.0.0"
11281 unordered-set "^2.0.1" 11501 unordered-set "^2.0.1"
11282 11502
11503uue@^3.1.0:
11504 version "3.1.2"
11505 resolved "https://registry.yarnpkg.com/uue/-/uue-3.1.2.tgz#e99368414e87200012eb37de4dbaebaa1c742ad2"
11506 integrity sha512-axKLXVqwtdI/czrjG0X8hyV1KLgeWx8F4KvSbvVCnS+RUvsQMGRjx0kfuZDXXqj0LYvVJmx3B9kWlKtEdRrJLg==
11507 dependencies:
11508 escape-string-regexp "~1.0.5"
11509 extend "~3.0.0"
11510
11283uuid@8.3.2, uuid@^8.3.2: 11511uuid@8.3.2, uuid@^8.3.2:
11284 version "8.3.2" 11512 version "8.3.2"
11285 resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" 11513 resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
@@ -11415,31 +11643,31 @@ wdio-geckodriver-service@^3.0.2:
11415 split2 "^4.1.0" 11643 split2 "^4.1.0"
11416 tcp-port-used "^1.0.2" 11644 tcp-port-used "^1.0.2"
11417 11645
11418webdriver@7.26.0: 11646webdriver@7.27.0:
11419 version "7.26.0" 11647 version "7.27.0"
11420 resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-7.26.0.tgz#cc20640ee9906c0126044449dfe9562b6277d14e" 11648 resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-7.27.0.tgz#41d23a6c38bd79ea868f0b9fb9c9e3d4b6e4f8bd"
11421 integrity sha512-T21T31wq29D/rmpFHcAahhdrvfsfXsLs/LBe2su7wL725ptOEoSssuDXjXMkwjf9MSUIXnTcUIz8oJGbKRUMwQ== 11649 integrity sha512-870uIBnrGJ86g3DdYjM+PHhqdWf6NxysSme1KIs6irWxK+LqcaWKWhN75PldE+04xJB2mVWt1tKn0NBBFTWeMg==
11422 dependencies: 11650 dependencies:
11423 "@types/node" "^18.0.0" 11651 "@types/node" "^18.0.0"
11424 "@wdio/config" "7.26.0" 11652 "@wdio/config" "7.26.0"
11425 "@wdio/logger" "7.26.0" 11653 "@wdio/logger" "7.26.0"
11426 "@wdio/protocols" "7.22.0" 11654 "@wdio/protocols" "7.27.0"
11427 "@wdio/types" "7.26.0" 11655 "@wdio/types" "7.26.0"
11428 "@wdio/utils" "7.26.0" 11656 "@wdio/utils" "7.26.0"
11429 got "^11.0.2" 11657 got "^11.0.2"
11430 ky "0.30.0" 11658 ky "0.30.0"
11431 lodash.merge "^4.6.1" 11659 lodash.merge "^4.6.1"
11432 11660
11433webdriverio@7.26.0: 11661webdriverio@7.29.1:
11434 version "7.26.0" 11662 version "7.29.1"
11435 resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-7.26.0.tgz#d6036d950ef96fb6cc29c6c5c9cfc452fcafa59a" 11663 resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-7.29.1.tgz#f71c9de317326cff36d22f6277669477e5340c6f"
11436 integrity sha512-7m9TeP871aYxZYKBI4GDh5aQZLN9Fd/PASu5K/jEIT65J4OBB6g5ZaycGFOmfNHCfjWKjwPXZuKiN1f2mcrcRg== 11664 integrity sha512-2xhoaZvV0tzOgnj8H/B4Yol8LTcIrWdfTdfe01d+ERtdzKCoqimmPNP4vpr2lVRVKL/TW4rfoBTBNvDUaJHe2g==
11437 dependencies: 11665 dependencies:
11438 "@types/aria-query" "^5.0.0" 11666 "@types/aria-query" "^5.0.0"
11439 "@types/node" "^18.0.0" 11667 "@types/node" "^18.0.0"
11440 "@wdio/config" "7.26.0" 11668 "@wdio/config" "7.26.0"
11441 "@wdio/logger" "7.26.0" 11669 "@wdio/logger" "7.26.0"
11442 "@wdio/protocols" "7.22.0" 11670 "@wdio/protocols" "7.27.0"
11443 "@wdio/repl" "7.26.0" 11671 "@wdio/repl" "7.26.0"
11444 "@wdio/types" "7.26.0" 11672 "@wdio/types" "7.26.0"
11445 "@wdio/utils" "7.26.0" 11673 "@wdio/utils" "7.26.0"
@@ -11447,8 +11675,8 @@ webdriverio@7.26.0:
11447 aria-query "^5.0.0" 11675 aria-query "^5.0.0"
11448 css-shorthand-properties "^1.1.1" 11676 css-shorthand-properties "^1.1.1"
11449 css-value "^0.0.1" 11677 css-value "^0.0.1"
11450 devtools "7.26.0" 11678 devtools "7.28.1"
11451 devtools-protocol "^0.0.1069585" 11679 devtools-protocol "^0.0.1085790"
11452 fs-extra "^10.0.0" 11680 fs-extra "^10.0.0"
11453 grapheme-splitter "^1.0.2" 11681 grapheme-splitter "^1.0.2"
11454 lodash.clonedeep "^4.5.0" 11682 lodash.clonedeep "^4.5.0"
@@ -11461,7 +11689,7 @@ webdriverio@7.26.0:
11461 resq "^1.9.1" 11689 resq "^1.9.1"
11462 rgb2hex "0.2.5" 11690 rgb2hex "0.2.5"
11463 serialize-error "^8.0.0" 11691 serialize-error "^8.0.0"
11464 webdriver "7.26.0" 11692 webdriver "7.27.0"
11465 11693
11466webidl-conversions@^3.0.0: 11694webidl-conversions@^3.0.0:
11467 version "3.0.1" 11695 version "3.0.1"
@@ -11732,7 +11960,7 @@ which-module@^2.0.0:
11732 resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" 11960 resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
11733 integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q== 11961 integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==
11734 11962
11735which-typed-array@^1.1.8: 11963which-typed-array@^1.1.9:
11736 version "1.1.9" 11964 version "1.1.9"
11737 resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.9.tgz#307cf898025848cf995e795e8423c7f337efbde6" 11965 resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.9.tgz#307cf898025848cf995e795e8423c7f337efbde6"
11738 integrity sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA== 11966 integrity sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==
@@ -11770,6 +11998,11 @@ wildcard@^2.0.0:
11770 resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" 11998 resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"
11771 integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== 11999 integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==
11772 12000
12001wildstring@1.0.9:
12002 version "1.0.9"
12003 resolved "https://registry.yarnpkg.com/wildstring/-/wildstring-1.0.9.tgz#82a696d5653c7d4ec9ba716859b6b53aba2761c5"
12004 integrity sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==
12005
11773word-wrap@^1.2.3: 12006word-wrap@^1.2.3:
11774 version "1.2.3" 12007 version "1.2.3"
11775 resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" 12008 resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
diff --git a/config/default.yaml b/config/default.yaml
index 20094ae8f..37059e9e0 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -37,6 +37,11 @@ rates_limit:
37 window: 10 minutes 37 window: 10 minutes
38 max: 10 38 max: 10
39 39
40oauth2:
41 token_lifetime:
42 access_token: '1 day'
43 refresh_token: '2 weeks'
44
40# Proxies to trust to get real client IP 45# Proxies to trust to get real client IP
41# If you run PeerTube just behind a local proxy (nginx), keep 'loopback' 46# If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
42# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) 47# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
@@ -377,9 +382,15 @@ contact_form:
377 382
378signup: 383signup:
379 enabled: false 384 enabled: false
385
380 limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited 386 limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
387
381 minimum_age: 16 # Used to configure the signup form 388 minimum_age: 16 # Used to configure the signup form
389
390 # Users fill a form to register so moderators can accept/reject the registration
391 requires_approval: true
382 requires_email_verification: false 392 requires_email_verification: false
393
383 filters: 394 filters:
384 cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist 395 cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
385 whitelist: [] 396 whitelist: []
diff --git a/config/dev.yaml b/config/dev.yaml
index ef93afc19..44856fb6a 100644
--- a/config/dev.yaml
+++ b/config/dev.yaml
@@ -8,6 +8,11 @@ webserver:
8secrets: 8secrets:
9 peertube: 'my super dev secret' 9 peertube: 'my super dev secret'
10 10
11rates_limit:
12 signup:
13 window: 5 minutes
14 max: 200
15
11database: 16database:
12 hostname: 'localhost' 17 hostname: 'localhost'
13 port: 5432 18 port: 5432
diff --git a/config/production.yaml.example b/config/production.yaml.example
index e8b354d01..906fb7e1f 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -35,6 +35,11 @@ rates_limit:
35 window: 10 minutes 35 window: 10 minutes
36 max: 10 36 max: 10
37 37
38oauth2:
39 token_lifetime:
40 access_token: '1 day'
41 refresh_token: '2 weeks'
42
38# Proxies to trust to get real client IP 43# Proxies to trust to get real client IP
39# If you run PeerTube just behind a local proxy (nginx), keep 'loopback' 44# If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
40# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) 45# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
@@ -387,9 +392,15 @@ contact_form:
387 392
388signup: 393signup:
389 enabled: false 394 enabled: false
395
390 limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited 396 limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
397
391 minimum_age: 16 # Used to configure the signup form 398 minimum_age: 16 # Used to configure the signup form
399
400 # Users fill a form to register so moderators can accept/reject the registration
401 requires_approval: true
392 requires_email_verification: false 402 requires_email_verification: false
403
393 filters: 404 filters:
394 cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist 405 cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
395 whitelist: [] 406 whitelist: []
diff --git a/config/test.yaml b/config/test.yaml
index 878d68cb9..94d74ffa5 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -74,6 +74,7 @@ cache:
74 74
75signup: 75signup:
76 enabled: true 76 enabled: true
77 requires_approval: false
77 requires_email_verification: false 78 requires_email_verification: false
78 79
79transcoding: 80transcoding:
diff --git a/package.json b/package.json
index d7d19afc2..b48f65bbd 100644
--- a/package.json
+++ b/package.json
@@ -225,7 +225,7 @@
225 "swagger-cli": "^4.0.2", 225 "swagger-cli": "^4.0.2",
226 "ts-node": "^10.8.1", 226 "ts-node": "^10.8.1",
227 "tsc-watch": "^5.0.3", 227 "tsc-watch": "^5.0.3",
228 "typescript": "^4.0.5" 228 "typescript": "~4.8"
229 }, 229 },
230 "bundlewatch": { 230 "bundlewatch": {
231 "files": [ 231 "files": [
diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts
index bcd7fe2a2..eca2fe09d 100755
--- a/scripts/i18n/create-custom-files.ts
+++ b/scripts/i18n/create-custom-files.ts
@@ -41,6 +41,7 @@ const playerKeys = {
41 'Volume': 'Volume', 41 'Volume': 'Volume',
42 'Codecs': 'Codecs', 42 'Codecs': 'Codecs',
43 'Color': 'Color', 43 'Color': 'Color',
44 'Go back to the live': 'Go back to the live',
44 'Connection Speed': 'Connection Speed', 45 'Connection Speed': 'Connection Speed',
45 'Network Activity': 'Network Activity', 46 'Network Activity': 'Network Activity',
46 'Total Transfered': 'Total Transfered', 47 'Total Transfered': 'Total Transfered',
@@ -89,7 +90,6 @@ Object.values(VIDEO_CATEGORIES)
89 90
90// More keys 91// More keys
91Object.assign(serverKeys, { 92Object.assign(serverKeys, {
92 Misc: 'Misc',
93 Unknown: 'Unknown' 93 Unknown: 'Unknown'
94}) 94})
95 95
diff --git a/server.ts b/server.ts
index dd595e951..f6a153fb7 100644
--- a/server.ts
+++ b/server.ts
@@ -279,7 +279,7 @@ app.use((err, _req, res: express.Response, _next) => {
279 }) 279 })
280}) 280})
281 281
282const server = createWebsocketTrackerServer(app) 282const { server, trackerServer } = createWebsocketTrackerServer(app)
283 283
284// ----------- Run ----------- 284// ----------- Run -----------
285 285
@@ -328,7 +328,8 @@ async function startApplication () {
328 VideoChannelSyncLatestScheduler.Instance.enable() 328 VideoChannelSyncLatestScheduler.Instance.enable()
329 VideoViewsBufferScheduler.Instance.enable() 329 VideoViewsBufferScheduler.Instance.enable()
330 GeoIPUpdateScheduler.Instance.enable() 330 GeoIPUpdateScheduler.Instance.enable()
331 OpenTelemetryMetrics.Instance.registerMetrics() 331
332 OpenTelemetryMetrics.Instance.registerMetrics({ trackerServer })
332 333
333 PluginManager.Instance.init(server) 334 PluginManager.Instance.init(server)
334 // Before PeerTubeSocket init 335 // Before PeerTubeSocket init
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 8e064fb5b..def320730 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -309,7 +309,7 @@ async function videoCommentsController (req: express.Request, res: express.Respo
309 if (redirectIfNotOwned(video.url, res)) return 309 if (redirectIfNotOwned(video.url, res)) return
310 310
311 const handler = async (start: number, count: number) => { 311 const handler = async (start: number, count: number) => {
312 const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count) 312 const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
313 313
314 return { 314 return {
315 total: result.total, 315 total: result.total,
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index f0fb43071..86434f382 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -193,6 +193,7 @@ function customConfig (): CustomConfig {
193 signup: { 193 signup: {
194 enabled: CONFIG.SIGNUP.ENABLED, 194 enabled: CONFIG.SIGNUP.ENABLED,
195 limit: CONFIG.SIGNUP.LIMIT, 195 limit: CONFIG.SIGNUP.LIMIT,
196 requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
196 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION, 197 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION,
197 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE 198 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE
198 }, 199 },
diff --git a/server/controllers/api/users/email-verification.ts b/server/controllers/api/users/email-verification.ts
new file mode 100644
index 000000000..230aaa9af
--- /dev/null
+++ b/server/controllers/api/users/email-verification.ts
@@ -0,0 +1,72 @@
1import express from 'express'
2import { HttpStatusCode } from '@shared/models'
3import { CONFIG } from '../../../initializers/config'
4import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
5import { asyncMiddleware, buildRateLimiter } from '../../../middlewares'
6import {
7 registrationVerifyEmailValidator,
8 usersAskSendVerifyEmailValidator,
9 usersVerifyEmailValidator
10} from '../../../middlewares/validators'
11
12const askSendEmailLimiter = buildRateLimiter({
13 windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
14 max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
15})
16
17const emailVerificationRouter = express.Router()
18
19emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ],
20 askSendEmailLimiter,
21 asyncMiddleware(usersAskSendVerifyEmailValidator),
22 asyncMiddleware(reSendVerifyUserEmail)
23)
24
25emailVerificationRouter.post('/:id/verify-email',
26 asyncMiddleware(usersVerifyEmailValidator),
27 asyncMiddleware(verifyUserEmail)
28)
29
30emailVerificationRouter.post('/registrations/:registrationId/verify-email',
31 asyncMiddleware(registrationVerifyEmailValidator),
32 asyncMiddleware(verifyRegistrationEmail)
33)
34
35// ---------------------------------------------------------------------------
36
37export {
38 emailVerificationRouter
39}
40
41async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
42 const user = res.locals.user
43 const registration = res.locals.userRegistration
44
45 if (user) await sendVerifyUserEmail(user)
46 else if (registration) await sendVerifyRegistrationEmail(registration)
47
48 return res.status(HttpStatusCode.NO_CONTENT_204).end()
49}
50
51async function verifyUserEmail (req: express.Request, res: express.Response) {
52 const user = res.locals.user
53 user.emailVerified = true
54
55 if (req.body.isPendingEmail === true) {
56 user.email = user.pendingEmail
57 user.pendingEmail = null
58 }
59
60 await user.save()
61
62 return res.status(HttpStatusCode.NO_CONTENT_204).end()
63}
64
65async function verifyRegistrationEmail (req: express.Request, res: express.Response) {
66 const registration = res.locals.userRegistration
67 registration.emailVerified = true
68
69 await registration.save()
70
71 return res.status(HttpStatusCode.NO_CONTENT_204).end()
72}
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index a8677a1d3..5a5a12e82 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -4,26 +4,21 @@ import { Hooks } from '@server/lib/plugins/hooks'
4import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 4import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
5import { MUserAccountDefault } from '@server/types/models' 5import { MUserAccountDefault } from '@server/types/models'
6import { pick } from '@shared/core-utils' 6import { pick } from '@shared/core-utils'
7import { HttpStatusCode, UserCreate, UserCreateResult, UserRegister, UserRight, UserUpdate } from '@shared/models' 7import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@shared/models'
8import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' 8import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
9import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
10import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' 10import { generateRandomString, getFormattedObjects } from '../../../helpers/utils'
11import { CONFIG } from '../../../initializers/config'
12import { WEBSERVER } from '../../../initializers/constants' 11import { WEBSERVER } from '../../../initializers/constants'
13import { sequelizeTypescript } from '../../../initializers/database' 12import { sequelizeTypescript } from '../../../initializers/database'
14import { Emailer } from '../../../lib/emailer' 13import { Emailer } from '../../../lib/emailer'
15import { Notifier } from '../../../lib/notifier'
16import { Redis } from '../../../lib/redis' 14import { Redis } from '../../../lib/redis'
17import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' 15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user'
18import { 16import {
19 adminUsersSortValidator, 17 adminUsersSortValidator,
20 asyncMiddleware, 18 asyncMiddleware,
21 asyncRetryTransactionMiddleware, 19 asyncRetryTransactionMiddleware,
22 authenticate, 20 authenticate,
23 buildRateLimiter,
24 ensureUserHasRight, 21 ensureUserHasRight,
25 ensureUserRegistrationAllowed,
26 ensureUserRegistrationAllowedForIP,
27 paginationValidator, 22 paginationValidator,
28 setDefaultPagination, 23 setDefaultPagination,
29 setDefaultSort, 24 setDefaultSort,
@@ -31,19 +26,17 @@ import {
31 usersAddValidator, 26 usersAddValidator,
32 usersGetValidator, 27 usersGetValidator,
33 usersListValidator, 28 usersListValidator,
34 usersRegisterValidator,
35 usersRemoveValidator, 29 usersRemoveValidator,
36 usersUpdateValidator 30 usersUpdateValidator
37} from '../../../middlewares' 31} from '../../../middlewares'
38import { 32import {
39 ensureCanModerateUser, 33 ensureCanModerateUser,
40 usersAskResetPasswordValidator, 34 usersAskResetPasswordValidator,
41 usersAskSendVerifyEmailValidator,
42 usersBlockingValidator, 35 usersBlockingValidator,
43 usersResetPasswordValidator, 36 usersResetPasswordValidator
44 usersVerifyEmailValidator
45} from '../../../middlewares/validators' 37} from '../../../middlewares/validators'
46import { UserModel } from '../../../models/user/user' 38import { UserModel } from '../../../models/user/user'
39import { emailVerificationRouter } from './email-verification'
47import { meRouter } from './me' 40import { meRouter } from './me'
48import { myAbusesRouter } from './my-abuses' 41import { myAbusesRouter } from './my-abuses'
49import { myBlocklistRouter } from './my-blocklist' 42import { myBlocklistRouter } from './my-blocklist'
@@ -51,22 +44,14 @@ import { myVideosHistoryRouter } from './my-history'
51import { myNotificationsRouter } from './my-notifications' 44import { myNotificationsRouter } from './my-notifications'
52import { mySubscriptionsRouter } from './my-subscriptions' 45import { mySubscriptionsRouter } from './my-subscriptions'
53import { myVideoPlaylistsRouter } from './my-video-playlists' 46import { myVideoPlaylistsRouter } from './my-video-playlists'
47import { registrationsRouter } from './registrations'
54import { twoFactorRouter } from './two-factor' 48import { twoFactorRouter } from './two-factor'
55 49
56const auditLogger = auditLoggerFactory('users') 50const auditLogger = auditLoggerFactory('users')
57 51
58const signupRateLimiter = buildRateLimiter({
59 windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
60 max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
61 skipFailedRequests: true
62})
63
64const askSendEmailLimiter = buildRateLimiter({
65 windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
66 max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
67})
68
69const usersRouter = express.Router() 52const usersRouter = express.Router()
53usersRouter.use('/', emailVerificationRouter)
54usersRouter.use('/', registrationsRouter)
70usersRouter.use('/', twoFactorRouter) 55usersRouter.use('/', twoFactorRouter)
71usersRouter.use('/', tokensRouter) 56usersRouter.use('/', tokensRouter)
72usersRouter.use('/', myNotificationsRouter) 57usersRouter.use('/', myNotificationsRouter)
@@ -122,14 +107,6 @@ usersRouter.post('/',
122 asyncRetryTransactionMiddleware(createUser) 107 asyncRetryTransactionMiddleware(createUser)
123) 108)
124 109
125usersRouter.post('/register',
126 signupRateLimiter,
127 asyncMiddleware(ensureUserRegistrationAllowed),
128 ensureUserRegistrationAllowedForIP,
129 asyncMiddleware(usersRegisterValidator),
130 asyncRetryTransactionMiddleware(registerUser)
131)
132
133usersRouter.put('/:id', 110usersRouter.put('/:id',
134 authenticate, 111 authenticate,
135 ensureUserHasRight(UserRight.MANAGE_USERS), 112 ensureUserHasRight(UserRight.MANAGE_USERS),
@@ -156,17 +133,6 @@ usersRouter.post('/:id/reset-password',
156 asyncMiddleware(resetUserPassword) 133 asyncMiddleware(resetUserPassword)
157) 134)
158 135
159usersRouter.post('/ask-send-verify-email',
160 askSendEmailLimiter,
161 asyncMiddleware(usersAskSendVerifyEmailValidator),
162 asyncMiddleware(reSendVerifyUserEmail)
163)
164
165usersRouter.post('/:id/verify-email',
166 asyncMiddleware(usersVerifyEmailValidator),
167 asyncMiddleware(verifyUserEmail)
168)
169
170// --------------------------------------------------------------------------- 136// ---------------------------------------------------------------------------
171 137
172export { 138export {
@@ -218,35 +184,6 @@ async function createUser (req: express.Request, res: express.Response) {
218 }) 184 })
219} 185}
220 186
221async function registerUser (req: express.Request, res: express.Response) {
222 const body: UserRegister = req.body
223
224 const userToCreate = buildUser({
225 ...pick(body, [ 'username', 'password', 'email' ]),
226
227 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
228 })
229
230 const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
231 userToCreate,
232 userDisplayName: body.displayName || undefined,
233 channelNames: body.channel
234 })
235
236 auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
237 logger.info('User %s with its channel and account registered.', body.username)
238
239 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
240 await sendVerifyUserEmail(user)
241 }
242
243 Notifier.Instance.notifyOnNewUserRegistration(user)
244
245 Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
246
247 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
248}
249
250async function unblockUser (req: express.Request, res: express.Response) { 187async function unblockUser (req: express.Request, res: express.Response) {
251 const user = res.locals.user 188 const user = res.locals.user
252 189
@@ -360,28 +297,6 @@ async function resetUserPassword (req: express.Request, res: express.Response) {
360 return res.status(HttpStatusCode.NO_CONTENT_204).end() 297 return res.status(HttpStatusCode.NO_CONTENT_204).end()
361} 298}
362 299
363async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
364 const user = res.locals.user
365
366 await sendVerifyUserEmail(user)
367
368 return res.status(HttpStatusCode.NO_CONTENT_204).end()
369}
370
371async function verifyUserEmail (req: express.Request, res: express.Response) {
372 const user = res.locals.user
373 user.emailVerified = true
374
375 if (req.body.isPendingEmail === true) {
376 user.email = user.pendingEmail
377 user.pendingEmail = null
378 }
379
380 await user.save()
381
382 return res.status(HttpStatusCode.NO_CONTENT_204).end()
383}
384
385async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) { 300async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) {
386 const oldUserAuditView = new UserAuditView(user.toFormattedJSON()) 301 const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
387 302
diff --git a/server/controllers/api/users/registrations.ts b/server/controllers/api/users/registrations.ts
new file mode 100644
index 000000000..5e213d6cc
--- /dev/null
+++ b/server/controllers/api/users/registrations.ts
@@ -0,0 +1,249 @@
1import express from 'express'
2import { Emailer } from '@server/lib/emailer'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { UserRegistrationModel } from '@server/models/user/user-registration'
5import { pick } from '@shared/core-utils'
6import {
7 HttpStatusCode,
8 UserRegister,
9 UserRegistrationRequest,
10 UserRegistrationState,
11 UserRegistrationUpdateState,
12 UserRight
13} from '@shared/models'
14import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
15import { logger } from '../../../helpers/logger'
16import { CONFIG } from '../../../initializers/config'
17import { Notifier } from '../../../lib/notifier'
18import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
19import {
20 acceptOrRejectRegistrationValidator,
21 asyncMiddleware,
22 asyncRetryTransactionMiddleware,
23 authenticate,
24 buildRateLimiter,
25 ensureUserHasRight,
26 ensureUserRegistrationAllowedFactory,
27 ensureUserRegistrationAllowedForIP,
28 getRegistrationValidator,
29 listRegistrationsValidator,
30 paginationValidator,
31 setDefaultPagination,
32 setDefaultSort,
33 userRegistrationsSortValidator,
34 usersDirectRegistrationValidator,
35 usersRequestRegistrationValidator
36} from '../../../middlewares'
37
38const auditLogger = auditLoggerFactory('users')
39
40const registrationRateLimiter = buildRateLimiter({
41 windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
42 max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
43 skipFailedRequests: true
44})
45
46const registrationsRouter = express.Router()
47
48registrationsRouter.post('/registrations/request',
49 registrationRateLimiter,
50 asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')),
51 ensureUserRegistrationAllowedForIP,
52 asyncMiddleware(usersRequestRegistrationValidator),
53 asyncRetryTransactionMiddleware(requestRegistration)
54)
55
56registrationsRouter.post('/registrations/:registrationId/accept',
57 authenticate,
58 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
59 asyncMiddleware(acceptOrRejectRegistrationValidator),
60 asyncRetryTransactionMiddleware(acceptRegistration)
61)
62registrationsRouter.post('/registrations/:registrationId/reject',
63 authenticate,
64 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
65 asyncMiddleware(acceptOrRejectRegistrationValidator),
66 asyncRetryTransactionMiddleware(rejectRegistration)
67)
68
69registrationsRouter.delete('/registrations/:registrationId',
70 authenticate,
71 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
72 asyncMiddleware(getRegistrationValidator),
73 asyncRetryTransactionMiddleware(deleteRegistration)
74)
75
76registrationsRouter.get('/registrations',
77 authenticate,
78 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
79 paginationValidator,
80 userRegistrationsSortValidator,
81 setDefaultSort,
82 setDefaultPagination,
83 listRegistrationsValidator,
84 asyncMiddleware(listRegistrations)
85)
86
87registrationsRouter.post('/register',
88 registrationRateLimiter,
89 asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')),
90 ensureUserRegistrationAllowedForIP,
91 asyncMiddleware(usersDirectRegistrationValidator),
92 asyncRetryTransactionMiddleware(registerUser)
93)
94
95// ---------------------------------------------------------------------------
96
97export {
98 registrationsRouter
99}
100
101// ---------------------------------------------------------------------------
102
103async function requestRegistration (req: express.Request, res: express.Response) {
104 const body: UserRegistrationRequest = req.body
105
106 const registration = new UserRegistrationModel({
107 ...pick(body, [ 'username', 'password', 'email', 'registrationReason' ]),
108
109 accountDisplayName: body.displayName,
110 channelDisplayName: body.channel?.displayName,
111 channelHandle: body.channel?.name,
112
113 state: UserRegistrationState.PENDING,
114
115 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
116 })
117
118 await registration.save()
119
120 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
121 await sendVerifyRegistrationEmail(registration)
122 }
123
124 Notifier.Instance.notifyOnNewRegistrationRequest(registration)
125
126 Hooks.runAction('action:api.user.requested-registration', { body, registration, req, res })
127
128 return res.json(registration.toFormattedJSON())
129}
130
131// ---------------------------------------------------------------------------
132
133async function acceptRegistration (req: express.Request, res: express.Response) {
134 const registration = res.locals.userRegistration
135 const body: UserRegistrationUpdateState = req.body
136
137 const userToCreate = buildUser({
138 username: registration.username,
139 password: registration.password,
140 email: registration.email,
141 emailVerified: registration.emailVerified
142 })
143 // We already encrypted password in registration model
144 userToCreate.skipPasswordEncryption = true
145
146 // TODO: handle conflicts if someone else created a channel handle/user handle/user email between registration and approval
147
148 const { user } = await createUserAccountAndChannelAndPlaylist({
149 userToCreate,
150 userDisplayName: registration.accountDisplayName,
151 channelNames: registration.channelHandle && registration.channelDisplayName
152 ? {
153 name: registration.channelHandle,
154 displayName: registration.channelDisplayName
155 }
156 : undefined
157 })
158
159 registration.userId = user.id
160 registration.state = UserRegistrationState.ACCEPTED
161 registration.moderationResponse = body.moderationResponse
162
163 await registration.save()
164
165 logger.info('Registration of %s accepted', registration.username)
166
167 if (body.preventEmailDelivery !== true) {
168 Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
169 }
170
171 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
172}
173
174async function rejectRegistration (req: express.Request, res: express.Response) {
175 const registration = res.locals.userRegistration
176 const body: UserRegistrationUpdateState = req.body
177
178 registration.state = UserRegistrationState.REJECTED
179 registration.moderationResponse = body.moderationResponse
180
181 await registration.save()
182
183 if (body.preventEmailDelivery !== true) {
184 Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
185 }
186
187 logger.info('Registration of %s rejected', registration.username)
188
189 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
190}
191
192// ---------------------------------------------------------------------------
193
194async function deleteRegistration (req: express.Request, res: express.Response) {
195 const registration = res.locals.userRegistration
196
197 await registration.destroy()
198
199 logger.info('Registration of %s deleted', registration.username)
200
201 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
202}
203
204// ---------------------------------------------------------------------------
205
206async function listRegistrations (req: express.Request, res: express.Response) {
207 const resultList = await UserRegistrationModel.listForApi({
208 start: req.query.start,
209 count: req.query.count,
210 sort: req.query.sort,
211 search: req.query.search
212 })
213
214 return res.json({
215 total: resultList.total,
216 data: resultList.data.map(d => d.toFormattedJSON())
217 })
218}
219
220// ---------------------------------------------------------------------------
221
222async function registerUser (req: express.Request, res: express.Response) {
223 const body: UserRegister = req.body
224
225 const userToCreate = buildUser({
226 ...pick(body, [ 'username', 'password', 'email' ]),
227
228 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
229 })
230
231 const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
232 userToCreate,
233 userDisplayName: body.displayName || undefined,
234 channelNames: body.channel
235 })
236
237 auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
238 logger.info('User %s with its channel and account registered.', body.username)
239
240 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
241 await sendVerifyUserEmail(user)
242 }
243
244 Notifier.Instance.notifyOnNewDirectRegistration(user)
245
246 Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
247
248 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
249}
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index f8a607170..947f7ca77 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -15,7 +15,7 @@ import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/vid
15import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model' 15import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model'
16import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model' 16import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
17import { resetSequelizeInstance } from '../../helpers/database-utils' 17import { resetSequelizeInstance } from '../../helpers/database-utils'
18import { buildNSFWFilter, createReqFiles } from '../../helpers/express-utils' 18import { createReqFiles } from '../../helpers/express-utils'
19import { logger } from '../../helpers/logger' 19import { logger } from '../../helpers/logger'
20import { getFormattedObjects } from '../../helpers/utils' 20import { getFormattedObjects } from '../../helpers/utils'
21import { CONFIG } from '../../initializers/config' 21import { CONFIG } from '../../initializers/config'
@@ -474,10 +474,7 @@ async function getVideoPlaylistVideos (req: express.Request, res: express.Respon
474 'filter:api.video-playlist.videos.list.result' 474 'filter:api.video-playlist.videos.list.result'
475 ) 475 )
476 476
477 const options = { 477 const options = { accountId: user?.Account?.id }
478 displayNSFW: buildNSFWFilter(res, req.query.nsfw),
479 accountId: user ? user.Account.id : undefined
480 }
481 return res.json(getFormattedObjects(resultList.data, resultList.total, options)) 478 return res.json(getFormattedObjects(resultList.data, resultList.total, options))
482} 479}
483 480
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index 44d64776c..70ca21500 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -1,4 +1,6 @@
1import { MCommentFormattable } from '@server/types/models'
1import express from 'express' 2import express from 'express'
3
2import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models' 4import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 5import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model' 6import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
@@ -109,7 +111,7 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
109 const video = res.locals.onlyVideo 111 const video = res.locals.onlyVideo
110 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 112 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
111 113
112 let resultList: ThreadsResultList<VideoCommentModel> 114 let resultList: ThreadsResultList<MCommentFormattable>
113 115
114 if (video.commentsEnabled === true) { 116 if (video.commentsEnabled === true) {
115 const apiOptions = await Hooks.wrapObject({ 117 const apiOptions = await Hooks.wrapObject({
@@ -144,12 +146,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
144 const video = res.locals.onlyVideo 146 const video = res.locals.onlyVideo
145 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 147 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
146 148
147 let resultList: ResultList<VideoCommentModel> 149 let resultList: ResultList<MCommentFormattable>
148 150
149 if (video.commentsEnabled === true) { 151 if (video.commentsEnabled === true) {
150 const apiOptions = await Hooks.wrapObject({ 152 const apiOptions = await Hooks.wrapObject({
151 videoId: video.id, 153 videoId: video.id,
152 isVideoOwned: video.isOwned(),
153 threadId: res.locals.videoCommentThread.id, 154 threadId: res.locals.videoCommentThread.id,
154 user 155 user
155 }, 'filter:api.video-thread-comments.list.params') 156 }, 'filter:api.video-thread-comments.list.params')
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts
index 009b6dfb6..22387c3e8 100644
--- a/server/controllers/api/videos/token.ts
+++ b/server/controllers/api/videos/token.ts
@@ -22,7 +22,7 @@ export {
22function generateToken (req: express.Request, res: express.Response) { 22function generateToken (req: express.Request, res: express.Response) {
23 const video = res.locals.onlyVideo 23 const video = res.locals.onlyVideo
24 24
25 const { token, expires } = VideoTokensManager.Instance.create(video.uuid) 25 const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
26 26
27 return res.json({ 27 return res.json({
28 files: { 28 files: {
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index 772fe734d..ef810a842 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -285,8 +285,8 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
285 content: toSafeHtml(video.description), 285 content: toSafeHtml(video.description),
286 author: [ 286 author: [
287 { 287 {
288 name: video.VideoChannel.Account.getDisplayName(), 288 name: video.VideoChannel.getDisplayName(),
289 link: video.VideoChannel.Account.Actor.url 289 link: video.VideoChannel.Actor.url
290 } 290 }
291 ], 291 ],
292 date: video.publishedAt, 292 date: video.publishedAt,
diff --git a/server/controllers/tracker.ts b/server/controllers/tracker.ts
index 19a8b2bc9..c4f3a8889 100644
--- a/server/controllers/tracker.ts
+++ b/server/controllers/tracker.ts
@@ -1,17 +1,22 @@
1import { Server as TrackerServer } from 'bittorrent-tracker' 1import { Server as TrackerServer } from 'bittorrent-tracker'
2import express from 'express' 2import express from 'express'
3import { createServer } from 'http' 3import { createServer } from 'http'
4import LRUCache from 'lru-cache'
4import proxyAddr from 'proxy-addr' 5import proxyAddr from 'proxy-addr'
5import { WebSocketServer } from 'ws' 6import { WebSocketServer } from 'ws'
6import { Redis } from '@server/lib/redis'
7import { logger } from '../helpers/logger' 7import { logger } from '../helpers/logger'
8import { CONFIG } from '../initializers/config' 8import { CONFIG } from '../initializers/config'
9import { TRACKER_RATE_LIMITS } from '../initializers/constants' 9import { LRU_CACHE, TRACKER_RATE_LIMITS } from '../initializers/constants'
10import { VideoFileModel } from '../models/video/video-file' 10import { VideoFileModel } from '../models/video/video-file'
11import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 11import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
12 12
13const trackerRouter = express.Router() 13const trackerRouter = express.Router()
14 14
15const blockedIPs = new LRUCache<string, boolean>({
16 max: LRU_CACHE.TRACKER_IPS.MAX_SIZE,
17 ttl: TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME
18})
19
15let peersIps = {} 20let peersIps = {}
16let peersIpInfoHash = {} 21let peersIpInfoHash = {}
17runPeersChecker() 22runPeersChecker()
@@ -55,8 +60,7 @@ const trackerServer = new TrackerServer({
55 60
56 // Close socket connection and block IP for a few time 61 // Close socket connection and block IP for a few time
57 if (params.type === 'ws') { 62 if (params.type === 'ws') {
58 Redis.Instance.setTrackerBlockIP(ip) 63 blockedIPs.set(ip, true)
59 .catch(err => logger.error('Cannot set tracker block ip.', { err }))
60 64
61 // setTimeout to wait filter response 65 // setTimeout to wait filter response
62 setTimeout(() => params.socket.close(), 0) 66 setTimeout(() => params.socket.close(), 0)
@@ -102,26 +106,22 @@ function createWebsocketTrackerServer (app: express.Application) {
102 if (request.url === '/tracker/socket') { 106 if (request.url === '/tracker/socket') {
103 const ip = proxyAddr(request, CONFIG.TRUST_PROXY) 107 const ip = proxyAddr(request, CONFIG.TRUST_PROXY)
104 108
105 Redis.Instance.doesTrackerBlockIPExist(ip) 109 if (blockedIPs.has(ip)) {
106 .then(result => { 110 logger.debug('Blocking IP %s from tracker.', ip)
107 if (result === true) {
108 logger.debug('Blocking IP %s from tracker.', ip)
109 111
110 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n') 112 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
111 socket.destroy() 113 socket.destroy()
112 return 114 return
113 } 115 }
114 116
115 // FIXME: typings 117 // FIXME: typings
116 return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request)) 118 return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
117 })
118 .catch(err => logger.error('Cannot check if tracker block ip exists.', { err }))
119 } 119 }
120 120
121 // Don't destroy socket, we have Socket.IO too 121 // Don't destroy socket, we have Socket.IO too
122 }) 122 })
123 123
124 return server 124 return { server, trackerServer }
125} 125}
126 126
127// --------------------------------------------------------------------------- 127// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 3dc5504e3..b3ab3ac64 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -103,7 +103,13 @@ function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
103// --------------------------------------------------------------------------- 103// ---------------------------------------------------------------------------
104 104
105function toCompleteUUID (value: string) { 105function toCompleteUUID (value: string) {
106 if (isShortUUID(value)) return shortToUUID(value) 106 if (isShortUUID(value)) {
107 try {
108 return shortToUUID(value)
109 } catch {
110 return null
111 }
112 }
107 113
108 return value 114 return value
109} 115}
diff --git a/server/helpers/custom-validators/user-registration.ts b/server/helpers/custom-validators/user-registration.ts
new file mode 100644
index 000000000..9da0bb08a
--- /dev/null
+++ b/server/helpers/custom-validators/user-registration.ts
@@ -0,0 +1,25 @@
1import validator from 'validator'
2import { CONSTRAINTS_FIELDS, USER_REGISTRATION_STATES } from '../../initializers/constants'
3import { exists } from './misc'
4
5const USER_REGISTRATIONS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USER_REGISTRATIONS
6
7function isRegistrationStateValid (value: string) {
8 return exists(value) && USER_REGISTRATION_STATES[value] !== undefined
9}
10
11function isRegistrationModerationResponseValid (value: string) {
12 return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.MODERATOR_MESSAGE)
13}
14
15function isRegistrationReasonValid (value: string) {
16 return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.REASON_MESSAGE)
17}
18
19// ---------------------------------------------------------------------------
20
21export {
22 isRegistrationStateValid,
23 isRegistrationModerationResponseValid,
24 isRegistrationReasonValid
25}
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
index 59ba005fe..d5b09ea03 100644
--- a/server/helpers/custom-validators/video-captions.ts
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -8,10 +8,11 @@ function isVideoCaptionLanguageValid (value: any) {
8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined 8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined
9} 9}
10 10
11const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) 11// MacOS sends application/octet-stream
12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 12const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ]
13 .map(m => `(${m})`) 13 .map(m => `(${m})`)
14 .join('|') 14 .join('|')
15
15function isVideoCaptionFile (files: UploadFilesForCheck, field: string) { 16function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
16 return isFileValid({ 17 return isFileValid({
17 files, 18 files,
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
index af93aea56..da8962cb6 100644
--- a/server/helpers/custom-validators/video-imports.ts
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -22,10 +22,11 @@ function isVideoImportStateValid (value: any) {
22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined 22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined
23} 23}
24 24
25const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT) 25// MacOS sends application/octet-stream
26 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 26const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ]
27 .map(m => `(${m})`) 27 .map(m => `(${m})`)
28 .join('|') 28 .join('|')
29
29function isVideoImportTorrentFile (files: UploadFilesForCheck) { 30function isVideoImportTorrentFile (files: UploadFilesForCheck) {
30 return isFileValid({ 31 return isFileValid({
31 files, 32 files,
diff --git a/server/helpers/decache.ts b/server/helpers/decache.ts
index e31973b7a..08ab545e4 100644
--- a/server/helpers/decache.ts
+++ b/server/helpers/decache.ts
@@ -68,7 +68,7 @@ function searchCache (moduleName: string, callback: (current: NodeModule) => voi
68}; 68};
69 69
70function removeCachedPath (pluginPath: string) { 70function removeCachedPath (pluginPath: string) {
71 const pathCache = (module.constructor as any)._pathCache 71 const pathCache = (module.constructor as any)._pathCache as { [ id: string ]: string[] }
72 72
73 Object.keys(pathCache).forEach(function (cacheKey) { 73 Object.keys(pathCache).forEach(function (cacheKey) {
74 if (cacheKey.includes(pluginPath)) { 74 if (cacheKey.includes(pluginPath)) {
diff --git a/server/helpers/memoize.ts b/server/helpers/memoize.ts
new file mode 100644
index 000000000..aa20e7d73
--- /dev/null
+++ b/server/helpers/memoize.ts
@@ -0,0 +1,12 @@
1import memoizee from 'memoizee'
2
3export function Memoize (config?: memoizee.Options<any>) {
4 return function (_target, _key, descriptor: PropertyDescriptor) {
5 const oldFunction = descriptor.value
6 const newFunction = memoizee(oldFunction, config)
7
8 descriptor.value = function () {
9 return newFunction.apply(this, arguments)
10 }
11 }
12}
diff --git a/server/helpers/youtube-dl/youtube-dl-cli.ts b/server/helpers/youtube-dl/youtube-dl-cli.ts
index a2f630953..765038cea 100644
--- a/server/helpers/youtube-dl/youtube-dl-cli.ts
+++ b/server/helpers/youtube-dl/youtube-dl-cli.ts
@@ -6,6 +6,7 @@ import { VideoResolution } from '@shared/models'
6import { logger, loggerTagsFactory } from '../logger' 6import { logger, loggerTagsFactory } from '../logger'
7import { getProxy, isProxyEnabled } from '../proxy' 7import { getProxy, isProxyEnabled } from '../proxy'
8import { isBinaryResponse, peertubeGot } from '../requests' 8import { isBinaryResponse, peertubeGot } from '../requests'
9import { OptionsOfBufferResponseBody } from 'got/dist/source'
9 10
10const lTags = loggerTagsFactory('youtube-dl') 11const lTags = loggerTagsFactory('youtube-dl')
11 12
@@ -28,7 +29,16 @@ export class YoutubeDLCLI {
28 29
29 logger.info('Updating youtubeDL binary from %s.', url, lTags()) 30 logger.info('Updating youtubeDL binary from %s.', url, lTags())
30 31
31 const gotOptions = { context: { bodyKBLimit: 20_000 }, responseType: 'buffer' as 'buffer' } 32 const gotOptions: OptionsOfBufferResponseBody = {
33 context: { bodyKBLimit: 20_000 },
34 responseType: 'buffer' as 'buffer'
35 }
36
37 if (process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN) {
38 gotOptions.headers = {
39 authorization: 'Bearer ' + process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN
40 }
41 }
32 42
33 try { 43 try {
34 let gotResult = await peertubeGot(url, gotOptions) 44 let gotResult = await peertubeGot(url, gotOptions)
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index c83fef425..0df7414be 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -4,7 +4,7 @@ import { getFFmpegVersion } from '@server/helpers/ffmpeg'
4import { uniqify } from '@shared/core-utils' 4import { uniqify } from '@shared/core-utils'
5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' 5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
6import { RecentlyAddedStrategy } from '../../shared/models/redundancy' 6import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
7import { isProdInstance, parseSemVersion } from '../helpers/core-utils' 7import { isProdInstance, parseBytes, parseSemVersion } from '../helpers/core-utils'
8import { isArray } from '../helpers/custom-validators/misc' 8import { isArray } from '../helpers/custom-validators/misc'
9import { logger } from '../helpers/logger' 9import { logger } from '../helpers/logger'
10import { ApplicationModel, getServerActor } from '../models/application/application' 10import { ApplicationModel, getServerActor } from '../models/application/application'
@@ -116,6 +116,11 @@ function checkEmailConfig () {
116 throw new Error('Emailer is disabled but you require signup email verification.') 116 throw new Error('Emailer is disabled but you require signup email verification.')
117 } 117 }
118 118
119 if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_APPROVAL) {
120 // eslint-disable-next-line max-len
121 logger.warn('Emailer is disabled but signup approval is enabled: PeerTube will not be able to send an email to the user upon acceptance/rejection of the registration request')
122 }
123
119 if (CONFIG.CONTACT_FORM.ENABLED) { 124 if (CONFIG.CONTACT_FORM.ENABLED) {
120 logger.warn('Emailer is disabled so the contact form will not work.') 125 logger.warn('Emailer is disabled so the contact form will not work.')
121 } 126 }
@@ -174,7 +179,8 @@ function checkRemoteRedundancyConfig () {
174function checkStorageConfig () { 179function checkStorageConfig () {
175 // Check storage directory locations 180 // Check storage directory locations
176 if (isProdInstance()) { 181 if (isProdInstance()) {
177 const configStorage = config.get('storage') 182 const configStorage = config.get<{ [ name: string ]: string }>('storage')
183
178 for (const key of Object.keys(configStorage)) { 184 for (const key of Object.keys(configStorage)) {
179 if (configStorage[key].startsWith('storage/')) { 185 if (configStorage[key].startsWith('storage/')) {
180 logger.warn( 186 logger.warn(
@@ -278,6 +284,11 @@ function checkObjectStorageConfig () {
278 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.' 284 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
279 ) 285 )
280 } 286 }
287
288 if (CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART > parseBytes('250MB')) {
289 // eslint-disable-next-line max-len
290 logger.warn(`Object storage max upload part seems to have a big value (${CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART} bytes). Consider using a lower one (like 100MB).`)
291 }
281 } 292 }
282} 293}
283 294
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 39713a266..8b4d49180 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -13,6 +13,7 @@ function checkMissedConfig () {
13 'webserver.https', 'webserver.hostname', 'webserver.port', 13 'webserver.https', 'webserver.hostname', 'webserver.port',
14 'secrets.peertube', 14 'secrets.peertube',
15 'trust_proxy', 15 'trust_proxy',
16 'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token',
16 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', 17 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
17 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 18 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
18 'email.body.signature', 'email.subject.prefix', 19 'email.body.signature', 'email.subject.prefix',
@@ -27,7 +28,7 @@ function checkMissedConfig () {
27 'csp.enabled', 'csp.report_only', 'csp.report_uri', 28 'csp.enabled', 'csp.report_only', 'csp.report_uri',
28 'security.frameguard.enabled', 29 'security.frameguard.enabled',
29 'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled', 30 'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled',
30 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', 'signup.minimum_age', 31 'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age',
31 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 32 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
32 'redundancy.videos.strategies', 'redundancy.videos.check_interval', 33 'redundancy.videos.strategies', 'redundancy.videos.check_interval',
33 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled', 34 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index c2f8b19fd..9685e7bfc 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -149,6 +149,12 @@ const CONFIG = {
149 HOSTNAME: config.get<string>('webserver.hostname'), 149 HOSTNAME: config.get<string>('webserver.hostname'),
150 PORT: config.get<number>('webserver.port') 150 PORT: config.get<number>('webserver.port')
151 }, 151 },
152 OAUTH2: {
153 TOKEN_LIFETIME: {
154 ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')),
155 REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token'))
156 }
157 },
152 RATES_LIMIT: { 158 RATES_LIMIT: {
153 API: { 159 API: {
154 WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')), 160 WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')),
@@ -299,6 +305,7 @@ const CONFIG = {
299 }, 305 },
300 SIGNUP: { 306 SIGNUP: {
301 get ENABLED () { return config.get<boolean>('signup.enabled') }, 307 get ENABLED () { return config.get<boolean>('signup.enabled') },
308 get REQUIRES_APPROVAL () { return config.get<boolean>('signup.requires_approval') },
302 get LIMIT () { return config.get<number>('signup.limit') }, 309 get LIMIT () { return config.get<number>('signup.limit') },
303 get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') }, 310 get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') },
304 get MINIMUM_AGE () { return config.get<number>('signup.minimum_age') }, 311 get MINIMUM_AGE () { return config.get<number>('signup.minimum_age') },
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 0e56f0c9f..992c86ed2 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -6,6 +6,7 @@ import { randomInt, root } from '@shared/core-utils'
6import { 6import {
7 AbuseState, 7 AbuseState,
8 JobType, 8 JobType,
9 UserRegistrationState,
9 VideoChannelSyncState, 10 VideoChannelSyncState,
10 VideoImportState, 11 VideoImportState,
11 VideoPrivacy, 12 VideoPrivacy,
@@ -25,7 +26,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
25 26
26// --------------------------------------------------------------------------- 27// ---------------------------------------------------------------------------
27 28
28const LAST_MIGRATION_VERSION = 745 29const LAST_MIGRATION_VERSION = 755
29 30
30// --------------------------------------------------------------------------- 31// ---------------------------------------------------------------------------
31 32
@@ -78,6 +79,8 @@ const SORTABLE_COLUMNS = {
78 ACCOUNT_FOLLOWERS: [ 'createdAt' ], 79 ACCOUNT_FOLLOWERS: [ 'createdAt' ],
79 CHANNEL_FOLLOWERS: [ 'createdAt' ], 80 CHANNEL_FOLLOWERS: [ 'createdAt' ],
80 81
82 USER_REGISTRATIONS: [ 'createdAt', 'state' ],
83
81 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ], 84 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ],
82 85
83 // Don't forget to update peertube-search-index with the same values 86 // Don't forget to update peertube-search-index with the same values
@@ -101,11 +104,6 @@ const SORTABLE_COLUMNS = {
101 VIDEO_REDUNDANCIES: [ 'name' ] 104 VIDEO_REDUNDANCIES: [ 'name' ]
102} 105}
103 106
104const OAUTH_LIFETIME = {
105 ACCESS_TOKEN: 3600 * 24, // 1 day, for upload
106 REFRESH_TOKEN: 1209600 // 2 weeks
107}
108
109const ROUTE_CACHE_LIFETIME = { 107const ROUTE_CACHE_LIFETIME = {
110 FEEDS: '15 minutes', 108 FEEDS: '15 minutes',
111 ROBOTS: '2 hours', 109 ROBOTS: '2 hours',
@@ -295,6 +293,10 @@ const CONSTRAINTS_FIELDS = {
295 ABUSE_MESSAGES: { 293 ABUSE_MESSAGES: {
296 MESSAGE: { min: 2, max: 3000 } // Length 294 MESSAGE: { min: 2, max: 3000 } // Length
297 }, 295 },
296 USER_REGISTRATIONS: {
297 REASON_MESSAGE: { min: 2, max: 3000 }, // Length
298 MODERATOR_MESSAGE: { min: 2, max: 3000 } // Length
299 },
298 VIDEO_BLACKLIST: { 300 VIDEO_BLACKLIST: {
299 REASON: { min: 2, max: 300 } // Length 301 REASON: { min: 2, max: 300 } // Length
300 }, 302 },
@@ -521,6 +523,12 @@ const ABUSE_STATES: { [ id in AbuseState ]: string } = {
521 [AbuseState.ACCEPTED]: 'Accepted' 523 [AbuseState.ACCEPTED]: 'Accepted'
522} 524}
523 525
526const USER_REGISTRATION_STATES: { [ id in UserRegistrationState ]: string } = {
527 [UserRegistrationState.PENDING]: 'Pending',
528 [UserRegistrationState.REJECTED]: 'Rejected',
529 [UserRegistrationState.ACCEPTED]: 'Accepted'
530}
531
524const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = { 532const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
525 [VideoPlaylistPrivacy.PUBLIC]: 'Public', 533 [VideoPlaylistPrivacy.PUBLIC]: 'Public',
526 [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', 534 [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
@@ -665,7 +673,7 @@ const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
665 673
666const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes 674const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
667 675
668const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes 676const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
669 677
670const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { 678const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
671 DO_NOT_LIST: 'do_not_list', 679 DO_NOT_LIST: 'do_not_list',
@@ -781,6 +789,9 @@ const LRU_CACHE = {
781 VIDEO_TOKENS: { 789 VIDEO_TOKENS: {
782 MAX_SIZE: 100_000, 790 MAX_SIZE: 100_000,
783 TTL: parseDurationToMs('8 hours') 791 TTL: parseDurationToMs('8 hours')
792 },
793 TRACKER_IPS: {
794 MAX_SIZE: 100_000
784 } 795 }
785} 796}
786 797
@@ -884,7 +895,7 @@ const TRACKER_RATE_LIMITS = {
884 INTERVAL: 60000 * 5, // 5 minutes 895 INTERVAL: 60000 * 5, // 5 minutes
885 ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval 896 ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval
886 ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval 897 ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval
887 BLOCK_IP_LIFETIME: 60000 * 3 // 3 minutes 898 BLOCK_IP_LIFETIME: parseDurationToMs('3 minutes')
888} 899}
889 900
890const P2P_MEDIA_LOADER_PEER_VERSION = 2 901const P2P_MEDIA_LOADER_PEER_VERSION = 2
@@ -1030,7 +1041,6 @@ export {
1030 JOB_ATTEMPTS, 1041 JOB_ATTEMPTS,
1031 AP_CLEANER, 1042 AP_CLEANER,
1032 LAST_MIGRATION_VERSION, 1043 LAST_MIGRATION_VERSION,
1033 OAUTH_LIFETIME,
1034 CUSTOM_HTML_TAG_COMMENTS, 1044 CUSTOM_HTML_TAG_COMMENTS,
1035 STATS_TIMESERIE, 1045 STATS_TIMESERIE,
1036 BROADCAST_CONCURRENCY, 1046 BROADCAST_CONCURRENCY,
@@ -1072,13 +1082,14 @@ export {
1072 VIDEO_TRANSCODING_FPS, 1082 VIDEO_TRANSCODING_FPS,
1073 FFMPEG_NICE, 1083 FFMPEG_NICE,
1074 ABUSE_STATES, 1084 ABUSE_STATES,
1085 USER_REGISTRATION_STATES,
1075 LRU_CACHE, 1086 LRU_CACHE,
1076 REQUEST_TIMEOUTS, 1087 REQUEST_TIMEOUTS,
1077 MAX_LOCAL_VIEWER_WATCH_SECTIONS, 1088 MAX_LOCAL_VIEWER_WATCH_SECTIONS,
1078 USER_PASSWORD_RESET_LIFETIME, 1089 USER_PASSWORD_RESET_LIFETIME,
1079 USER_PASSWORD_CREATE_LIFETIME, 1090 USER_PASSWORD_CREATE_LIFETIME,
1080 MEMOIZE_TTL, 1091 MEMOIZE_TTL,
1081 USER_EMAIL_VERIFY_LIFETIME, 1092 EMAIL_VERIFY_LIFETIME,
1082 OVERVIEWS, 1093 OVERVIEWS,
1083 SCHEDULER_INTERVALS_MS, 1094 SCHEDULER_INTERVALS_MS,
1084 REPEAT_JOBS, 1095 REPEAT_JOBS,
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index f55f40df0..96145f489 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -5,7 +5,9 @@ import { TrackerModel } from '@server/models/server/tracker'
5import { VideoTrackerModel } from '@server/models/server/video-tracker' 5import { VideoTrackerModel } from '@server/models/server/video-tracker'
6import { UserModel } from '@server/models/user/user' 6import { UserModel } from '@server/models/user/user'
7import { UserNotificationModel } from '@server/models/user/user-notification' 7import { UserNotificationModel } from '@server/models/user/user-notification'
8import { UserRegistrationModel } from '@server/models/user/user-registration'
8import { UserVideoHistoryModel } from '@server/models/user/user-video-history' 9import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
10import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
9import { VideoJobInfoModel } from '@server/models/video/video-job-info' 11import { VideoJobInfoModel } from '@server/models/video/video-job-info'
10import { VideoLiveSessionModel } from '@server/models/video/video-live-session' 12import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
11import { VideoSourceModel } from '@server/models/video/video-source' 13import { VideoSourceModel } from '@server/models/video/video-source'
@@ -50,7 +52,6 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
50import { VideoTagModel } from '../models/video/video-tag' 52import { VideoTagModel } from '../models/video/video-tag'
51import { VideoViewModel } from '../models/view/video-view' 53import { VideoViewModel } from '../models/view/video-view'
52import { CONFIG } from './config' 54import { CONFIG } from './config'
53import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
54 55
55require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 56require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
56 57
@@ -155,7 +156,8 @@ async function initDatabaseModels (silent: boolean) {
155 PluginModel, 156 PluginModel,
156 ActorCustomPageModel, 157 ActorCustomPageModel,
157 VideoJobInfoModel, 158 VideoJobInfoModel,
158 VideoChannelSyncModel 159 VideoChannelSyncModel,
160 UserRegistrationModel
159 ]) 161 ])
160 162
161 // Check extensions exist in the database 163 // Check extensions exist in the database
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index f5d8eedf1..f48f348a7 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -51,8 +51,7 @@ function removeCacheAndTmpDirectories () {
51 const tasks: Promise<any>[] = [] 51 const tasks: Promise<any>[] = []
52 52
53 // Cache directories 53 // Cache directories
54 for (const key of Object.keys(cacheDirectories)) { 54 for (const dir of cacheDirectories) {
55 const dir = cacheDirectories[key]
56 tasks.push(removeDirectoryOrContent(dir)) 55 tasks.push(removeDirectoryOrContent(dir))
57 } 56 }
58 57
@@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () {
87 } 86 }
88 87
89 // Cache directories 88 // Cache directories
90 for (const key of Object.keys(cacheDirectories)) { 89 for (const dir of cacheDirectories) {
91 const dir = cacheDirectories[key]
92 tasks.push(ensureDir(dir)) 90 tasks.push(ensureDir(dir))
93 } 91 }
94 92
diff --git a/server/initializers/migrations/0750-user-registration.ts b/server/initializers/migrations/0750-user-registration.ts
new file mode 100644
index 000000000..15bbfd3fd
--- /dev/null
+++ b/server/initializers/migrations/0750-user-registration.ts
@@ -0,0 +1,58 @@
1
2import * as Sequelize from 'sequelize'
3
4async function up (utils: {
5 transaction: Sequelize.Transaction
6 queryInterface: Sequelize.QueryInterface
7 sequelize: Sequelize.Sequelize
8 db: any
9}): Promise<void> {
10 {
11 const query = `
12 CREATE TABLE IF NOT EXISTS "userRegistration" (
13 "id" serial,
14 "state" integer NOT NULL,
15 "registrationReason" text NOT NULL,
16 "moderationResponse" text,
17 "password" varchar(255),
18 "username" varchar(255) NOT NULL,
19 "email" varchar(400) NOT NULL,
20 "emailVerified" boolean,
21 "accountDisplayName" varchar(255),
22 "channelHandle" varchar(255),
23 "channelDisplayName" varchar(255),
24 "userId" integer REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
25 "createdAt" timestamp with time zone NOT NULL,
26 "updatedAt" timestamp with time zone NOT NULL,
27 PRIMARY KEY ("id")
28 );
29 `
30 await utils.sequelize.query(query, { transaction: utils.transaction })
31 }
32
33 {
34 await utils.queryInterface.addColumn('userNotification', 'userRegistrationId', {
35 type: Sequelize.INTEGER,
36 defaultValue: null,
37 allowNull: true,
38 references: {
39 model: 'userRegistration',
40 key: 'id'
41 },
42 onUpdate: 'CASCADE',
43 onDelete: 'SET NULL'
44 }, { transaction: utils.transaction })
45 }
46}
47
48async function down (utils: {
49 queryInterface: Sequelize.QueryInterface
50 transaction: Sequelize.Transaction
51}) {
52 await utils.queryInterface.dropTable('videoChannelSync', { transaction: utils.transaction })
53}
54
55export {
56 up,
57 down
58}
diff --git a/server/initializers/migrations/0755-unique-viewer-url.ts b/server/initializers/migrations/0755-unique-viewer-url.ts
new file mode 100644
index 000000000..b3dff9258
--- /dev/null
+++ b/server/initializers/migrations/0755-unique-viewer-url.ts
@@ -0,0 +1,27 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 const { transaction } = utils
10
11 const query = 'DELETE FROM "localVideoViewer" t1 ' +
12 'USING (SELECT MIN(id) as id, "url" FROM "localVideoViewer" GROUP BY "url" HAVING COUNT(*) > 1) t2 ' +
13 'WHERE t1."url" = t2."url" AND t1.id <> t2.id'
14
15 await utils.sequelize.query(query, { transaction })
16}
17
18async function down (utils: {
19 queryInterface: Sequelize.QueryInterface
20 transaction: Sequelize.Transaction
21}) {
22}
23
24export {
25 up,
26 down
27}
diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts
index 053112801..bc5b74257 100644
--- a/server/lib/auth/external-auth.ts
+++ b/server/lib/auth/external-auth.ts
@@ -1,26 +1,35 @@
1 1
2import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users' 2import {
3 isUserAdminFlagsValid,
4 isUserDisplayNameValid,
5 isUserRoleValid,
6 isUserUsernameValid,
7 isUserVideoQuotaDailyValid,
8 isUserVideoQuotaValid
9} from '@server/helpers/custom-validators/users'
3import { logger } from '@server/helpers/logger' 10import { logger } from '@server/helpers/logger'
4import { generateRandomString } from '@server/helpers/utils' 11import { generateRandomString } from '@server/helpers/utils'
5import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants' 12import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
6import { PluginManager } from '@server/lib/plugins/plugin-manager' 13import { PluginManager } from '@server/lib/plugins/plugin-manager'
7import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 14import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
15import { MUser } from '@server/types/models'
8import { 16import {
9 RegisterServerAuthenticatedResult, 17 RegisterServerAuthenticatedResult,
10 RegisterServerAuthPassOptions, 18 RegisterServerAuthPassOptions,
11 RegisterServerExternalAuthenticatedResult 19 RegisterServerExternalAuthenticatedResult
12} from '@server/types/plugins/register-server-auth.model' 20} from '@server/types/plugins/register-server-auth.model'
13import { UserRole } from '@shared/models' 21import { UserAdminFlag, UserRole } from '@shared/models'
22import { BypassLogin } from './oauth-model'
23
24export type ExternalUser =
25 Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
26 { displayName: string }
14 27
15// Token is the key, expiration date is the value 28// Token is the key, expiration date is the value
16const authBypassTokens = new Map<string, { 29const authBypassTokens = new Map<string, {
17 expires: Date 30 expires: Date
18 user: { 31 user: ExternalUser
19 username: string 32 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
20 email: string
21 displayName: string
22 role: UserRole
23 }
24 authName: string 33 authName: string
25 npmName: string 34 npmName: string
26}>() 35}>()
@@ -56,7 +65,8 @@ async function onExternalUserAuthenticated (options: {
56 expires, 65 expires,
57 user, 66 user,
58 npmName, 67 npmName,
59 authName 68 authName,
69 userUpdater: authResult.userUpdater
60 }) 70 })
61 71
62 // Cleanup expired tokens 72 // Cleanup expired tokens
@@ -78,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) {
78 return tokenModel?.authName 88 return tokenModel?.authName
79} 89}
80 90
81async function getBypassFromPasswordGrant (username: string, password: string) { 91async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
82 const plugins = PluginManager.Instance.getIdAndPassAuths() 92 const plugins = PluginManager.Instance.getIdAndPassAuths()
83 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] 93 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
84 94
@@ -133,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
133 bypass: true, 143 bypass: true,
134 pluginName: pluginAuth.npmName, 144 pluginName: pluginAuth.npmName,
135 authName: authOptions.authName, 145 authName: authOptions.authName,
136 user: buildUserResult(loginResult) 146 user: buildUserResult(loginResult),
147 userUpdater: loginResult.userUpdater
137 } 148 }
138 } catch (err) { 149 } catch (err) {
139 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) 150 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
@@ -143,7 +154,7 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
143 return undefined 154 return undefined
144} 155}
145 156
146function getBypassFromExternalAuth (username: string, externalAuthToken: string) { 157function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
147 const obj = authBypassTokens.get(externalAuthToken) 158 const obj = authBypassTokens.get(externalAuthToken)
148 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') 159 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
149 160
@@ -167,33 +178,29 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string)
167 bypass: true, 178 bypass: true,
168 pluginName: npmName, 179 pluginName: npmName,
169 authName, 180 authName,
181 userUpdater: obj.userUpdater,
170 user 182 user
171 } 183 }
172} 184}
173 185
174function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { 186function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
175 if (!isUserUsernameValid(result.username)) { 187 const returnError = (field: string) => {
176 logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username }) 188 logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
177 return false 189 return false
178 } 190 }
179 191
180 if (!result.email) { 192 if (!isUserUsernameValid(result.username)) return returnError('username')
181 logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email }) 193 if (!result.email) return returnError('email')
182 return false
183 }
184 194
185 // role is optional 195 // Following fields are optional
186 if (result.role && !isUserRoleValid(result.role)) { 196 if (result.role && !isUserRoleValid(result.role)) return returnError('role')
187 logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role }) 197 if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
188 return false 198 if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
189 } 199 if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
200 if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
190 201
191 // display name is optional 202 if (result.userUpdater && typeof result.userUpdater !== 'function') {
192 if (result.displayName && !isUserDisplayNameValid(result.displayName)) { 203 logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
193 logger.error(
194 'Auth method %s of plugin %s did not provide a valid display name.',
195 authName, npmName, { displayName: result.displayName }
196 )
197 return false 204 return false
198 } 205 }
199 206
@@ -205,7 +212,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
205 username: pluginResult.username, 212 username: pluginResult.username,
206 email: pluginResult.email, 213 email: pluginResult.email,
207 role: pluginResult.role ?? UserRole.USER, 214 role: pluginResult.role ?? UserRole.USER,
208 displayName: pluginResult.displayName || pluginResult.username 215 displayName: pluginResult.displayName || pluginResult.username,
216
217 adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
218
219 videoQuota: pluginResult.videoQuota,
220 videoQuotaDaily: pluginResult.videoQuotaDaily
209 } 221 }
210} 222}
211 223
diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts
index 322b69e3a..43909284f 100644
--- a/server/lib/auth/oauth-model.ts
+++ b/server/lib/auth/oauth-model.ts
@@ -1,11 +1,13 @@
1import express from 'express' 1import express from 'express'
2import { AccessDeniedError } from '@node-oauth/oauth2-server' 2import { AccessDeniedError } from '@node-oauth/oauth2-server'
3import { PluginManager } from '@server/lib/plugins/plugin-manager' 3import { PluginManager } from '@server/lib/plugins/plugin-manager'
4import { AccountModel } from '@server/models/account/account'
5import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types'
4import { MOAuthClient } from '@server/types/models' 6import { MOAuthClient } from '@server/types/models'
5import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 7import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
6import { MUser } from '@server/types/models/user/user' 8import { MUser, MUserDefault } from '@server/types/models/user/user'
7import { pick } from '@shared/core-utils' 9import { pick } from '@shared/core-utils'
8import { UserRole } from '@shared/models/users/user-role' 10import { AttributesOnly } from '@shared/typescript-utils'
9import { logger } from '../../helpers/logger' 11import { logger } from '../../helpers/logger'
10import { CONFIG } from '../../initializers/config' 12import { CONFIG } from '../../initializers/config'
11import { OAuthClientModel } from '../../models/oauth/oauth-client' 13import { OAuthClientModel } from '../../models/oauth/oauth-client'
@@ -13,6 +15,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token'
13import { UserModel } from '../../models/user/user' 15import { UserModel } from '../../models/user/user'
14import { findAvailableLocalActorName } from '../local-actor' 16import { findAvailableLocalActorName } from '../local-actor'
15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' 17import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
18import { ExternalUser } from './external-auth'
16import { TokensCache } from './tokens-cache' 19import { TokensCache } from './tokens-cache'
17 20
18type TokenInfo = { 21type TokenInfo = {
@@ -26,12 +29,8 @@ export type BypassLogin = {
26 bypass: boolean 29 bypass: boolean
27 pluginName: string 30 pluginName: string
28 authName?: string 31 authName?: string
29 user: { 32 user: ExternalUser
30 username: string 33 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
31 email: string
32 displayName: string
33 role: UserRole
34 }
35} 34}
36 35
37async function getAccessToken (bearerToken: string) { 36async function getAccessToken (bearerToken: string) {
@@ -89,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
89 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) 88 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
90 89
91 let user = await UserModel.loadByEmail(bypassLogin.user.email) 90 let user = await UserModel.loadByEmail(bypassLogin.user.email)
91
92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) 92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
93 else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
93 94
94 // Cannot create a user 95 // Cannot create a user
95 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.') 96 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
@@ -219,16 +220,11 @@ export {
219 220
220// --------------------------------------------------------------------------- 221// ---------------------------------------------------------------------------
221 222
222async function createUserFromExternal (pluginAuth: string, options: { 223async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
223 username: string 224 const username = await findAvailableLocalActorName(userOptions.username)
224 email: string
225 role: UserRole
226 displayName: string
227}) {
228 const username = await findAvailableLocalActorName(options.username)
229 225
230 const userToCreate = buildUser({ 226 const userToCreate = buildUser({
231 ...pick(options, [ 'email', 'role' ]), 227 ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
232 228
233 username, 229 username,
234 emailVerified: null, 230 emailVerified: null,
@@ -238,12 +234,57 @@ async function createUserFromExternal (pluginAuth: string, options: {
238 234
239 const { user } = await createUserAccountAndChannelAndPlaylist({ 235 const { user } = await createUserAccountAndChannelAndPlaylist({
240 userToCreate, 236 userToCreate,
241 userDisplayName: options.displayName 237 userDisplayName: userOptions.displayName
242 }) 238 })
243 239
244 return user 240 return user
245} 241}
246 242
243async function updateUserFromExternal (
244 user: MUserDefault,
245 userOptions: ExternalUser,
246 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
247) {
248 if (!userUpdater) return user
249
250 {
251 type UserAttributeKeys = keyof AttributesOnly<UserModel>
252 const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
253 role: 'role',
254 adminFlags: 'adminFlags',
255 videoQuota: 'videoQuota',
256 videoQuotaDaily: 'videoQuotaDaily'
257 }
258
259 for (const modelKey of Object.keys(mappingKeys)) {
260 const pluginOptionKey = mappingKeys[modelKey]
261
262 const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
263 user.set(modelKey, newValue)
264 }
265 }
266
267 {
268 type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
269 const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
270 name: 'displayName'
271 }
272
273 for (const modelKey of Object.keys(mappingKeys)) {
274 const optionKey = mappingKeys[modelKey]
275
276 const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
277 user.Account.set(modelKey, newValue)
278 }
279 }
280
281 logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
282
283 user.Account = await user.Account.save()
284
285 return user.save()
286}
287
247function checkUserValidityOrThrow (user: MUser) { 288function checkUserValidityOrThrow (user: MUser) {
248 if (user.blocked) throw new AccessDeniedError('User is blocked.') 289 if (user.blocked) throw new AccessDeniedError('User is blocked.')
249} 290}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
index bc0d4301f..887c4f7c9 100644
--- a/server/lib/auth/oauth.ts
+++ b/server/lib/auth/oauth.ts
@@ -10,20 +10,32 @@ import OAuth2Server, {
10} from '@node-oauth/oauth2-server' 10} from '@node-oauth/oauth2-server'
11import { randomBytesPromise } from '@server/helpers/core-utils' 11import { randomBytesPromise } from '@server/helpers/core-utils'
12import { isOTPValid } from '@server/helpers/otp' 12import { isOTPValid } from '@server/helpers/otp'
13import { CONFIG } from '@server/initializers/config'
14import { UserRegistrationModel } from '@server/models/user/user-registration'
13import { MOAuthClient } from '@server/types/models' 15import { MOAuthClient } from '@server/types/models'
14import { sha1 } from '@shared/extra-utils' 16import { sha1 } from '@shared/extra-utils'
15import { HttpStatusCode } from '@shared/models' 17import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@shared/models'
16import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' 18import { OTP } from '../../initializers/constants'
17import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' 19import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
18 20
19class MissingTwoFactorError extends Error { 21class MissingTwoFactorError extends Error {
20 code = HttpStatusCode.UNAUTHORIZED_401 22 code = HttpStatusCode.UNAUTHORIZED_401
21 name = 'missing_two_factor' 23 name = ServerErrorCode.MISSING_TWO_FACTOR
22} 24}
23 25
24class InvalidTwoFactorError extends Error { 26class InvalidTwoFactorError extends Error {
25 code = HttpStatusCode.BAD_REQUEST_400 27 code = HttpStatusCode.BAD_REQUEST_400
26 name = 'invalid_two_factor' 28 name = ServerErrorCode.INVALID_TWO_FACTOR
29}
30
31class RegistrationWaitingForApproval extends Error {
32 code = HttpStatusCode.BAD_REQUEST_400
33 name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL
34}
35
36class RegistrationApprovalRejected extends Error {
37 code = HttpStatusCode.BAD_REQUEST_400
38 name = ServerErrorCode.ACCOUNT_APPROVAL_REJECTED
27} 39}
28 40
29/** 41/**
@@ -32,8 +44,9 @@ class InvalidTwoFactorError extends Error {
32 * 44 *
33 */ 45 */
34const oAuthServer = new OAuth2Server({ 46const oAuthServer = new OAuth2Server({
35 accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, 47 // Wants seconds
36 refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, 48 accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
49 refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
37 50
38 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications 51 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
39 model: require('./oauth-model') 52 model: require('./oauth-model')
@@ -126,7 +139,17 @@ async function handlePasswordGrant (options: {
126 } 139 }
127 140
128 const user = await getUser(request.body.username, request.body.password, bypassLogin) 141 const user = await getUser(request.body.username, request.body.password, bypassLogin)
129 if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid') 142 if (!user) {
143 const registration = await UserRegistrationModel.loadByEmailOrUsername(request.body.username)
144
145 if (registration?.state === UserRegistrationState.REJECTED) {
146 throw new RegistrationApprovalRejected('Registration approval for this account has been rejected')
147 } else if (registration?.state === UserRegistrationState.PENDING) {
148 throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval')
149 }
150
151 throw new InvalidGrantError('Invalid grant: user credentials are invalid')
152 }
130 153
131 if (user.otpSecret) { 154 if (user.otpSecret) {
132 if (!request.headers[OTP.HEADER_NAME]) { 155 if (!request.headers[OTP.HEADER_NAME]) {
@@ -182,10 +205,10 @@ function generateRandomToken () {
182 205
183function getTokenExpiresAt (type: 'access' | 'refresh') { 206function getTokenExpiresAt (type: 'access' | 'refresh') {
184 const lifetime = type === 'access' 207 const lifetime = type === 'access'
185 ? OAUTH_LIFETIME.ACCESS_TOKEN 208 ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
186 : OAUTH_LIFETIME.REFRESH_TOKEN 209 : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
187 210
188 return new Date(Date.now() + lifetime * 1000) 211 return new Date(Date.now() + lifetime)
189} 212}
190 213
191async function buildToken () { 214async function buildToken () {
diff --git a/server/lib/auth/tokens-cache.ts b/server/lib/auth/tokens-cache.ts
index 410708a35..43efc7d02 100644
--- a/server/lib/auth/tokens-cache.ts
+++ b/server/lib/auth/tokens-cache.ts
@@ -36,8 +36,8 @@ export class TokensCache {
36 const token = this.userHavingToken.get(userId) 36 const token = this.userHavingToken.get(userId)
37 37
38 if (token !== undefined) { 38 if (token !== undefined) {
39 this.accessTokenCache.del(token) 39 this.accessTokenCache.delete(token)
40 this.userHavingToken.del(userId) 40 this.userHavingToken.delete(userId)
41 } 41 }
42 } 42 }
43 43
@@ -45,8 +45,8 @@ export class TokensCache {
45 const tokenModel = this.accessTokenCache.get(token) 45 const tokenModel = this.accessTokenCache.get(token)
46 46
47 if (tokenModel !== undefined) { 47 if (tokenModel !== undefined) {
48 this.userHavingToken.del(tokenModel.userId) 48 this.userHavingToken.delete(tokenModel.userId)
49 this.accessTokenCache.del(token) 49 this.accessTokenCache.delete(token)
50 } 50 }
51 } 51 }
52} 52}
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index 39b662eb2..f5c3e4745 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -3,13 +3,13 @@ import { merge } from 'lodash'
3import { createTransport, Transporter } from 'nodemailer' 3import { createTransport, Transporter } from 'nodemailer'
4import { join } from 'path' 4import { join } from 'path'
5import { arrayify, root } from '@shared/core-utils' 5import { arrayify, root } from '@shared/core-utils'
6import { EmailPayload } from '@shared/models' 6import { EmailPayload, UserRegistrationState } from '@shared/models'
7import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model' 7import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model'
8import { isTestOrDevInstance } from '../helpers/core-utils' 8import { isTestOrDevInstance } from '../helpers/core-utils'
9import { bunyanLogger, logger } from '../helpers/logger' 9import { bunyanLogger, logger } from '../helpers/logger'
10import { CONFIG, isEmailEnabled } from '../initializers/config' 10import { CONFIG, isEmailEnabled } from '../initializers/config'
11import { WEBSERVER } from '../initializers/constants' 11import { WEBSERVER } from '../initializers/constants'
12import { MUser } from '../types/models' 12import { MRegistration, MUser } from '../types/models'
13import { JobQueue } from './job-queue' 13import { JobQueue } from './job-queue'
14 14
15const Email = require('email-templates') 15const Email = require('email-templates')
@@ -62,7 +62,9 @@ class Emailer {
62 subject: 'Reset your account password', 62 subject: 'Reset your account password',
63 locals: { 63 locals: {
64 username, 64 username,
65 resetPasswordUrl 65 resetPasswordUrl,
66
67 hideNotificationPreferencesLink: true
66 } 68 }
67 } 69 }
68 70
@@ -76,21 +78,33 @@ class Emailer {
76 subject: 'Create your account password', 78 subject: 'Create your account password',
77 locals: { 79 locals: {
78 username, 80 username,
79 createPasswordUrl 81 createPasswordUrl,
82
83 hideNotificationPreferencesLink: true
80 } 84 }
81 } 85 }
82 86
83 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload }) 87 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
84 } 88 }
85 89
86 addVerifyEmailJob (username: string, to: string, verifyEmailUrl: string) { 90 addVerifyEmailJob (options: {
91 username: string
92 isRegistrationRequest: boolean
93 to: string
94 verifyEmailUrl: string
95 }) {
96 const { username, isRegistrationRequest, to, verifyEmailUrl } = options
97
87 const emailPayload: EmailPayload = { 98 const emailPayload: EmailPayload = {
88 template: 'verify-email', 99 template: 'verify-email',
89 to: [ to ], 100 to: [ to ],
90 subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`, 101 subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
91 locals: { 102 locals: {
92 username, 103 username,
93 verifyEmailUrl 104 verifyEmailUrl,
105 isRegistrationRequest,
106
107 hideNotificationPreferencesLink: true
94 } 108 }
95 } 109 }
96 110
@@ -123,7 +137,33 @@ class Emailer {
123 body, 137 body,
124 138
125 // There are not notification preferences for the contact form 139 // There are not notification preferences for the contact form
126 hideNotificationPreferences: true 140 hideNotificationPreferencesLink: true
141 }
142 }
143
144 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
145 }
146
147 addUserRegistrationRequestProcessedJob (registration: MRegistration) {
148 let template: string
149 let subject: string
150 if (registration.state === UserRegistrationState.ACCEPTED) {
151 template = 'user-registration-request-accepted'
152 subject = `Your registration request for ${registration.username} has been accepted`
153 } else {
154 template = 'user-registration-request-rejected'
155 subject = `Your registration request for ${registration.username} has been rejected`
156 }
157
158 const to = registration.email
159 const emailPayload: EmailPayload = {
160 to: [ to ],
161 template,
162 subject,
163 locals: {
164 username: registration.username,
165 moderationResponse: registration.moderationResponse,
166 loginLink: WEBSERVER.URL + '/login'
127 } 167 }
128 } 168 }
129 169
diff --git a/server/lib/emails/common/base.pug b/server/lib/emails/common/base.pug
index 6da5648e4..41e94564d 100644
--- a/server/lib/emails/common/base.pug
+++ b/server/lib/emails/common/base.pug
@@ -222,19 +222,9 @@ body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule:
222 td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;') 222 td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;')
223 br 223 br
224 //- Clear Spacer : END 224 //- Clear Spacer : END
225 //- 1 Column Text : BEGIN
226 if username
227 tr
228 td(style='background-color: #cccccc;')
229 table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
230 tr
231 td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;')
232 p(style='margin: 0;')
233 | You are receiving this email as part of your notification settings on #{instanceName} for your account #{username}.
234 //- 1 Column Text : END
235 //- Email Body : END 225 //- Email Body : END
236 //- Email Footer : BEGIN 226 //- Email Footer : BEGIN
237 unless hideNotificationPreferences 227 unless hideNotificationPreferencesLink
238 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;') 228 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
239 tr 229 tr
240 td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;') 230 td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;')
diff --git a/server/lib/emails/user-registration-request-accepted/html.pug b/server/lib/emails/user-registration-request-accepted/html.pug
new file mode 100644
index 000000000..7a52c3fe1
--- /dev/null
+++ b/server/lib/emails/user-registration-request-accepted/html.pug
@@ -0,0 +1,10 @@
1extends ../common/greetings
2
3block title
4 | Congratulation #{username}, your registration request has been accepted!
5
6block content
7 p Your registration request has been accepted.
8 p Moderators sent you the following message:
9 blockquote(style='white-space: pre-wrap') #{moderationResponse}
10 p Your account has been created and you can login on #[a(href=loginLink) #{loginLink}]
diff --git a/server/lib/emails/user-registration-request-rejected/html.pug b/server/lib/emails/user-registration-request-rejected/html.pug
new file mode 100644
index 000000000..ec0aa8dfe
--- /dev/null
+++ b/server/lib/emails/user-registration-request-rejected/html.pug
@@ -0,0 +1,9 @@
1extends ../common/greetings
2
3block title
4 | Registration request of your account #{username} has rejected
5
6block content
7 p Your registration request has been rejected.
8 p Moderators sent you the following message:
9 blockquote(style='white-space: pre-wrap') #{moderationResponse}
diff --git a/server/lib/emails/user-registration-request/html.pug b/server/lib/emails/user-registration-request/html.pug
new file mode 100644
index 000000000..64898f3f2
--- /dev/null
+++ b/server/lib/emails/user-registration-request/html.pug
@@ -0,0 +1,9 @@
1extends ../common/greetings
2
3block title
4 | A new user wants to register
5
6block content
7 p User #{registration.username} wants to register on your PeerTube instance with the following reason:
8 blockquote(style='white-space: pre-wrap') #{registration.registrationReason}
9 p You can accept or reject the registration request in the #[a(href=`${WEBSERVER.URL}/admin/moderation/registrations/list`) administration].
diff --git a/server/lib/emails/verify-email/html.pug b/server/lib/emails/verify-email/html.pug
index be9dde21b..19ef65f75 100644
--- a/server/lib/emails/verify-email/html.pug
+++ b/server/lib/emails/verify-email/html.pug
@@ -1,17 +1,19 @@
1extends ../common/greetings 1extends ../common/greetings
2 2
3block title 3block title
4 | Account verification 4 | Email verification
5 5
6block content 6block content
7 p Welcome to #{instanceName}! 7 if isRegistrationRequest
8 p. 8 p You just requested an account on #[a(href=WEBSERVER.URL) #{instanceName}].
9 You just created an account at #[a(href=WEBSERVER.URL) #{instanceName}]. 9 else
10 Your username there is: #{username}. 10 p You just created an account on #[a(href=WEBSERVER.URL) #{instanceName}].
11 p. 11
12 To start using your account you must verify your email first! 12 if isRegistrationRequest
13 Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you. 13 p To complete your registration request you must verify your email first!
14 p. 14 else
15 If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}] 15 p To start using your account you must verify your email first!
16 p. 16
17 If you are not the person who initiated this request, please ignore this email. 17 p Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.
18 p If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
19 p If you are not the person who initiated this request, please ignore this email.
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index 866aa1ed0..8597eb000 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -184,7 +184,7 @@ class JobQueue {
184 184
185 this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST 185 this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST
186 186
187 for (const handlerName of (Object.keys(handlers) as JobType[])) { 187 for (const handlerName of Object.keys(handlers)) {
188 this.buildWorker(handlerName) 188 this.buildWorker(handlerName)
189 this.buildQueue(handlerName) 189 this.buildQueue(handlerName)
190 this.buildQueueScheduler(handlerName) 190 this.buildQueueScheduler(handlerName)
diff --git a/server/lib/notifier/notifier.ts b/server/lib/notifier/notifier.ts
index 66cfc31c4..920c55df0 100644
--- a/server/lib/notifier/notifier.ts
+++ b/server/lib/notifier/notifier.ts
@@ -1,4 +1,4 @@
1import { MUser, MUserDefault } from '@server/types/models/user' 1import { MRegistration, MUser, MUserDefault } from '@server/types/models/user'
2import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist' 2import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
3import { UserNotificationSettingValue } from '../../../shared/models/users' 3import { UserNotificationSettingValue } from '../../../shared/models/users'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
@@ -13,6 +13,7 @@ import {
13 AbuseStateChangeForReporter, 13 AbuseStateChangeForReporter,
14 AutoFollowForInstance, 14 AutoFollowForInstance,
15 CommentMention, 15 CommentMention,
16 DirectRegistrationForModerators,
16 FollowForInstance, 17 FollowForInstance,
17 FollowForUser, 18 FollowForUser,
18 ImportFinishedForOwner, 19 ImportFinishedForOwner,
@@ -30,7 +31,7 @@ import {
30 OwnedPublicationAfterAutoUnblacklist, 31 OwnedPublicationAfterAutoUnblacklist,
31 OwnedPublicationAfterScheduleUpdate, 32 OwnedPublicationAfterScheduleUpdate,
32 OwnedPublicationAfterTranscoding, 33 OwnedPublicationAfterTranscoding,
33 RegistrationForModerators, 34 RegistrationRequestForModerators,
34 StudioEditionFinishedForOwner, 35 StudioEditionFinishedForOwner,
35 UnblacklistForOwner 36 UnblacklistForOwner
36} from './shared' 37} from './shared'
@@ -47,7 +48,8 @@ class Notifier {
47 newBlacklist: [ NewBlacklistForOwner ], 48 newBlacklist: [ NewBlacklistForOwner ],
48 unblacklist: [ UnblacklistForOwner ], 49 unblacklist: [ UnblacklistForOwner ],
49 importFinished: [ ImportFinishedForOwner ], 50 importFinished: [ ImportFinishedForOwner ],
50 userRegistration: [ RegistrationForModerators ], 51 directRegistration: [ DirectRegistrationForModerators ],
52 registrationRequest: [ RegistrationRequestForModerators ],
51 userFollow: [ FollowForUser ], 53 userFollow: [ FollowForUser ],
52 instanceFollow: [ FollowForInstance ], 54 instanceFollow: [ FollowForInstance ],
53 autoInstanceFollow: [ AutoFollowForInstance ], 55 autoInstanceFollow: [ AutoFollowForInstance ],
@@ -138,13 +140,20 @@ class Notifier {
138 }) 140 })
139 } 141 }
140 142
141 notifyOnNewUserRegistration (user: MUserDefault): void { 143 notifyOnNewDirectRegistration (user: MUserDefault): void {
142 const models = this.notificationModels.userRegistration 144 const models = this.notificationModels.directRegistration
143 145
144 this.sendNotifications(models, user) 146 this.sendNotifications(models, user)
145 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err })) 147 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
146 } 148 }
147 149
150 notifyOnNewRegistrationRequest (registration: MRegistration): void {
151 const models = this.notificationModels.registrationRequest
152
153 this.sendNotifications(models, registration)
154 .catch(err => logger.error('Cannot notify moderators of new registration request (%s).', registration.username, { err }))
155 }
156
148 notifyOfNewUserFollow (actorFollow: MActorFollowFull): void { 157 notifyOfNewUserFollow (actorFollow: MActorFollowFull): void {
149 const models = this.notificationModels.userFollow 158 const models = this.notificationModels.userFollow
150 159
diff --git a/server/lib/notifier/shared/instance/registration-for-moderators.ts b/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
index e92467424..5044f2068 100644
--- a/server/lib/notifier/shared/instance/registration-for-moderators.ts
+++ b/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
@@ -6,7 +6,7 @@ import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi
6import { UserNotificationType, UserRight } from '@shared/models' 6import { UserNotificationType, UserRight } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification' 7import { AbstractNotification } from '../common/abstract-notification'
8 8
9export class RegistrationForModerators extends AbstractNotification <MUserDefault> { 9export class DirectRegistrationForModerators extends AbstractNotification <MUserDefault> {
10 private moderators: MUserDefault[] 10 private moderators: MUserDefault[]
11 11
12 async prepare () { 12 async prepare () {
@@ -40,7 +40,7 @@ export class RegistrationForModerators extends AbstractNotification <MUserDefaul
40 return { 40 return {
41 template: 'user-registered', 41 template: 'user-registered',
42 to, 42 to,
43 subject: `a new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`, 43 subject: `A new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
44 locals: { 44 locals: {
45 user: this.payload 45 user: this.payload
46 } 46 }
diff --git a/server/lib/notifier/shared/instance/index.ts b/server/lib/notifier/shared/instance/index.ts
index c3bb22aec..8c75a8ee9 100644
--- a/server/lib/notifier/shared/instance/index.ts
+++ b/server/lib/notifier/shared/instance/index.ts
@@ -1,3 +1,4 @@
1export * from './new-peertube-version-for-admins' 1export * from './new-peertube-version-for-admins'
2export * from './new-plugin-version-for-admins' 2export * from './new-plugin-version-for-admins'
3export * from './registration-for-moderators' 3export * from './direct-registration-for-moderators'
4export * from './registration-request-for-moderators'
diff --git a/server/lib/notifier/shared/instance/registration-request-for-moderators.ts b/server/lib/notifier/shared/instance/registration-request-for-moderators.ts
new file mode 100644
index 000000000..79920245a
--- /dev/null
+++ b/server/lib/notifier/shared/instance/registration-request-for-moderators.ts
@@ -0,0 +1,48 @@
1import { logger } from '@server/helpers/logger'
2import { UserModel } from '@server/models/user/user'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MRegistration, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType, UserRight } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8export class RegistrationRequestForModerators extends AbstractNotification <MRegistration> {
9 private moderators: MUserDefault[]
10
11 async prepare () {
12 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_REGISTRATIONS)
13 }
14
15 log () {
16 logger.info('Notifying %s moderators of new user registration request of %s.', this.moderators.length, this.payload.username)
17 }
18
19 getSetting (user: MUserWithNotificationSetting) {
20 return user.NotificationSetting.newUserRegistration
21 }
22
23 getTargetUsers () {
24 return this.moderators
25 }
26
27 createNotification (user: MUserWithNotificationSetting) {
28 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
29 type: UserNotificationType.NEW_USER_REGISTRATION_REQUEST,
30 userId: user.id,
31 userRegistrationId: this.payload.id
32 })
33 notification.UserRegistration = this.payload
34
35 return notification
36 }
37
38 createEmail (to: string) {
39 return {
40 template: 'user-registration-request',
41 to,
42 subject: `A new user wants to register: ${this.payload.username}`,
43 locals: {
44 registration: this.payload
45 }
46 }
47 }
48}
diff --git a/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
new file mode 100644
index 000000000..ef40c0fa9
--- /dev/null
+++ b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
@@ -0,0 +1,51 @@
1import { Meter } from '@opentelemetry/api'
2
3export class BittorrentTrackerObserversBuilder {
4
5 constructor (private readonly meter: Meter, private readonly trackerServer: any) {
6
7 }
8
9 buildObservers () {
10 const activeInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_active_infohashes_total', {
11 description: 'Total active infohashes in the PeerTube BitTorrent Tracker'
12 })
13 const inactiveInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_inactive_infohashes_total', {
14 description: 'Total inactive infohashes in the PeerTube BitTorrent Tracker'
15 })
16 const peers = this.meter.createObservableGauge('peertube_bittorrent_tracker_peers_total', {
17 description: 'Total peers in the PeerTube BitTorrent Tracker'
18 })
19
20 this.meter.addBatchObservableCallback(observableResult => {
21 const infohashes = Object.keys(this.trackerServer.torrents)
22
23 const counters = {
24 activeInfohashes: 0,
25 inactiveInfohashes: 0,
26 peers: 0,
27 uncompletedPeers: 0
28 }
29
30 for (const infohash of infohashes) {
31 const content = this.trackerServer.torrents[infohash]
32
33 const peers = content.peers
34 if (peers.keys.length !== 0) counters.activeInfohashes++
35 else counters.inactiveInfohashes++
36
37 for (const peerId of peers.keys) {
38 const peer = peers.peek(peerId)
39 if (peer == null) return
40
41 counters.peers++
42 }
43 }
44
45 observableResult.observe(activeInfohashes, counters.activeInfohashes)
46 observableResult.observe(inactiveInfohashes, counters.inactiveInfohashes)
47 observableResult.observe(peers, counters.peers)
48 }, [ activeInfohashes, inactiveInfohashes, peers ])
49 }
50
51}
diff --git a/server/lib/opentelemetry/metric-helpers/index.ts b/server/lib/opentelemetry/metric-helpers/index.ts
index 775d954ba..47b24a54f 100644
--- a/server/lib/opentelemetry/metric-helpers/index.ts
+++ b/server/lib/opentelemetry/metric-helpers/index.ts
@@ -1,3 +1,4 @@
1export * from './bittorrent-tracker-observers-builder'
1export * from './lives-observers-builder' 2export * from './lives-observers-builder'
2export * from './job-queue-observers-builder' 3export * from './job-queue-observers-builder'
3export * from './nodejs-observers-builder' 4export * from './nodejs-observers-builder'
diff --git a/server/lib/opentelemetry/metrics.ts b/server/lib/opentelemetry/metrics.ts
index 226d514c0..9cc067e4a 100644
--- a/server/lib/opentelemetry/metrics.ts
+++ b/server/lib/opentelemetry/metrics.ts
@@ -7,6 +7,7 @@ import { CONFIG } from '@server/initializers/config'
7import { MVideoImmutable } from '@server/types/models' 7import { MVideoImmutable } from '@server/types/models'
8import { PlaybackMetricCreate } from '@shared/models' 8import { PlaybackMetricCreate } from '@shared/models'
9import { 9import {
10 BittorrentTrackerObserversBuilder,
10 JobQueueObserversBuilder, 11 JobQueueObserversBuilder,
11 LivesObserversBuilder, 12 LivesObserversBuilder,
12 NodeJSObserversBuilder, 13 NodeJSObserversBuilder,
@@ -41,7 +42,7 @@ class OpenTelemetryMetrics {
41 }) 42 })
42 } 43 }
43 44
44 registerMetrics () { 45 registerMetrics (options: { trackerServer: any }) {
45 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return 46 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return
46 47
47 logger.info('Registering Open Telemetry metrics') 48 logger.info('Registering Open Telemetry metrics')
@@ -80,6 +81,9 @@ class OpenTelemetryMetrics {
80 81
81 const viewersObserversBuilder = new ViewersObserversBuilder(this.meter) 82 const viewersObserversBuilder = new ViewersObserversBuilder(this.meter)
82 viewersObserversBuilder.buildObservers() 83 viewersObserversBuilder.buildObservers()
84
85 const bittorrentTrackerObserversBuilder = new BittorrentTrackerObserversBuilder(this.meter, options.trackerServer)
86 bittorrentTrackerObserversBuilder.buildObservers()
83 } 87 }
84 88
85 observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) { 89 observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
index 7b1def6e3..66383af46 100644
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ b/server/lib/plugins/plugin-helpers-builder.ts
@@ -209,6 +209,10 @@ function buildConfigHelpers () {
209 return WEBSERVER.URL 209 return WEBSERVER.URL
210 }, 210 },
211 211
212 getServerListeningConfig () {
213 return { hostname: CONFIG.LISTEN.HOSTNAME, port: CONFIG.LISTEN.PORT }
214 },
215
212 getServerConfig () { 216 getServerConfig () {
213 return ServerConfigManager.Instance.getServerConfig() 217 return ServerConfigManager.Instance.getServerConfig()
214 } 218 }
@@ -245,7 +249,7 @@ function buildUserHelpers () {
245 }, 249 },
246 250
247 getAuthUser: (res: express.Response) => { 251 getAuthUser: (res: express.Response) => {
248 const user = res.locals.oauth?.token?.User 252 const user = res.locals.oauth?.token?.User || res.locals.videoFileToken?.user
249 if (!user) return undefined 253 if (!user) return undefined
250 254
251 return UserModel.loadByIdFull(user.id) 255 return UserModel.loadByIdFull(user.id)
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index c0e9aece7..3706d2228 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -8,9 +8,8 @@ import {
8 AP_CLEANER, 8 AP_CLEANER,
9 CONTACT_FORM_LIFETIME, 9 CONTACT_FORM_LIFETIME,
10 RESUMABLE_UPLOAD_SESSION_LIFETIME, 10 RESUMABLE_UPLOAD_SESSION_LIFETIME,
11 TRACKER_RATE_LIMITS,
12 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, 11 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
13 USER_EMAIL_VERIFY_LIFETIME, 12 EMAIL_VERIFY_LIFETIME,
14 USER_PASSWORD_CREATE_LIFETIME, 13 USER_PASSWORD_CREATE_LIFETIME,
15 USER_PASSWORD_RESET_LIFETIME, 14 USER_PASSWORD_RESET_LIFETIME,
16 VIEW_LIFETIME, 15 VIEW_LIFETIME,
@@ -125,16 +124,28 @@ class Redis {
125 124
126 /* ************ Email verification ************ */ 125 /* ************ Email verification ************ */
127 126
128 async setVerifyEmailVerificationString (userId: number) { 127 async setUserVerifyEmailVerificationString (userId: number) {
129 const generatedString = await generateRandomString(32) 128 const generatedString = await generateRandomString(32)
130 129
131 await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME) 130 await this.setValue(this.generateUserVerifyEmailKey(userId), generatedString, EMAIL_VERIFY_LIFETIME)
132 131
133 return generatedString 132 return generatedString
134 } 133 }
135 134
136 async getVerifyEmailLink (userId: number) { 135 async getUserVerifyEmailLink (userId: number) {
137 return this.getValue(this.generateVerifyEmailKey(userId)) 136 return this.getValue(this.generateUserVerifyEmailKey(userId))
137 }
138
139 async setRegistrationVerifyEmailVerificationString (registrationId: number) {
140 const generatedString = await generateRandomString(32)
141
142 await this.setValue(this.generateRegistrationVerifyEmailKey(registrationId), generatedString, EMAIL_VERIFY_LIFETIME)
143
144 return generatedString
145 }
146
147 async getRegistrationVerifyEmailLink (registrationId: number) {
148 return this.getValue(this.generateRegistrationVerifyEmailKey(registrationId))
138 } 149 }
139 150
140 /* ************ Contact form per IP ************ */ 151 /* ************ Contact form per IP ************ */
@@ -157,16 +168,6 @@ class Redis {
157 return this.exists(this.generateIPViewKey(ip, videoUUID)) 168 return this.exists(this.generateIPViewKey(ip, videoUUID))
158 } 169 }
159 170
160 /* ************ Tracker IP block ************ */
161
162 setTrackerBlockIP (ip: string) {
163 return this.setValue(this.generateTrackerBlockIPKey(ip), '1', TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME)
164 }
165
166 async doesTrackerBlockIPExist (ip: string) {
167 return this.exists(this.generateTrackerBlockIPKey(ip))
168 }
169
170 /* ************ Video views stats ************ */ 171 /* ************ Video views stats ************ */
171 172
172 addVideoViewStats (videoId: number) { 173 addVideoViewStats (videoId: number) {
@@ -357,16 +358,16 @@ class Redis {
357 return 'two-factor-request-' + userId + '-' + token 358 return 'two-factor-request-' + userId + '-' + token
358 } 359 }
359 360
360 private generateVerifyEmailKey (userId: number) { 361 private generateUserVerifyEmailKey (userId: number) {
361 return 'verify-email-' + userId 362 return 'verify-email-user-' + userId
362 } 363 }
363 364
364 private generateIPViewKey (ip: string, videoUUID: string) { 365 private generateRegistrationVerifyEmailKey (registrationId: number) {
365 return `views-${videoUUID}-${ip}` 366 return 'verify-email-registration-' + registrationId
366 } 367 }
367 368
368 private generateTrackerBlockIPKey (ip: string) { 369 private generateIPViewKey (ip: string, videoUUID: string) {
369 return `tracker-block-ip-${ip}` 370 return `views-${videoUUID}-${ip}`
370 } 371 }
371 372
372 private generateContactFormKey (ip: string) { 373 private generateContactFormKey (ip: string) {
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
index 78a9546ae..e87e2854f 100644
--- a/server/lib/server-config-manager.ts
+++ b/server/lib/server-config-manager.ts
@@ -261,10 +261,17 @@ class ServerConfigManager {
261 async getServerConfig (ip?: string): Promise<ServerConfig> { 261 async getServerConfig (ip?: string): Promise<ServerConfig> {
262 const { allowed } = await Hooks.wrapPromiseFun( 262 const { allowed } = await Hooks.wrapPromiseFun(
263 isSignupAllowed, 263 isSignupAllowed,
264
264 { 265 {
265 ip 266 ip,
267 signupMode: CONFIG.SIGNUP.REQUIRES_APPROVAL
268 ? 'request-registration'
269 : 'direct-registration'
266 }, 270 },
267 'filter:api.user.signup.allowed.result' 271
272 CONFIG.SIGNUP.REQUIRES_APPROVAL
273 ? 'filter:api.user.request-signup.allowed.result'
274 : 'filter:api.user.signup.allowed.result'
268 ) 275 )
269 276
270 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip) 277 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
@@ -273,6 +280,7 @@ class ServerConfigManager {
273 allowed, 280 allowed,
274 allowedForCurrentIP, 281 allowedForCurrentIP,
275 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE, 282 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE,
283 requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
276 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION 284 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
277 } 285 }
278 286
diff --git a/server/lib/signup.ts b/server/lib/signup.ts
index f094531eb..f19232621 100644
--- a/server/lib/signup.ts
+++ b/server/lib/signup.ts
@@ -4,11 +4,24 @@ import { UserModel } from '../models/user/user'
4 4
5const isCidr = require('is-cidr') 5const isCidr = require('is-cidr')
6 6
7async function isSignupAllowed (): Promise<{ allowed: boolean, errorMessage?: string }> { 7export type SignupMode = 'direct-registration' | 'request-registration'
8
9async function isSignupAllowed (options: {
10 signupMode: SignupMode
11
12 ip: string // For plugins
13 body?: any
14}): Promise<{ allowed: boolean, errorMessage?: string }> {
15 const { signupMode } = options
16
8 if (CONFIG.SIGNUP.ENABLED === false) { 17 if (CONFIG.SIGNUP.ENABLED === false) {
9 return { allowed: false } 18 return { allowed: false }
10 } 19 }
11 20
21 if (signupMode === 'direct-registration' && CONFIG.SIGNUP.REQUIRES_APPROVAL === true) {
22 return { allowed: false }
23 }
24
12 // No limit and signup is enabled 25 // No limit and signup is enabled
13 if (CONFIG.SIGNUP.LIMIT === -1) { 26 if (CONFIG.SIGNUP.LIMIT === -1) {
14 return { allowed: true } 27 return { allowed: true }
diff --git a/server/lib/sync-channel.ts b/server/lib/sync-channel.ts
index 10167ee38..3a805a943 100644
--- a/server/lib/sync-channel.ts
+++ b/server/lib/sync-channel.ts
@@ -76,7 +76,7 @@ export async function synchronizeChannel (options: {
76 76
77 await JobQueue.Instance.createJobWithChildren(parent, children) 77 await JobQueue.Instance.createJobWithChildren(parent, children)
78 } catch (err) { 78 } catch (err) {
79 logger.error(`Failed to import channel ${channel.name}`, { err }) 79 logger.error(`Failed to import ${externalChannelUrl} in channel ${channel.name}`, { err })
80 channelSync.state = VideoChannelSyncState.FAILED 80 channelSync.state = VideoChannelSyncState.FAILED
81 await channelSync.save() 81 await channelSync.save()
82 } 82 }
diff --git a/server/lib/user.ts b/server/lib/user.ts
index 2e433da04..ffb57944a 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -10,7 +10,7 @@ import { sequelizeTypescript } from '../initializers/database'
10import { AccountModel } from '../models/account/account' 10import { AccountModel } from '../models/account/account'
11import { UserNotificationSettingModel } from '../models/user/user-notification-setting' 11import { UserNotificationSettingModel } from '../models/user/user-notification-setting'
12import { MAccountDefault, MChannelActor } from '../types/models' 12import { MAccountDefault, MChannelActor } from '../types/models'
13import { MUser, MUserDefault, MUserId } from '../types/models/user' 13import { MRegistration, MUser, MUserDefault, MUserId } from '../types/models/user'
14import { generateAndSaveActorKeys } from './activitypub/actors' 14import { generateAndSaveActorKeys } from './activitypub/actors'
15import { getLocalAccountActivityPubUrl } from './activitypub/url' 15import { getLocalAccountActivityPubUrl } from './activitypub/url'
16import { Emailer } from './emailer' 16import { Emailer } from './emailer'
@@ -97,7 +97,7 @@ async function createUserAccountAndChannelAndPlaylist (parameters: {
97 }) 97 })
98 userCreated.Account = accountCreated 98 userCreated.Account = accountCreated
99 99
100 const channelAttributes = await buildChannelAttributes(userCreated, t, channelNames) 100 const channelAttributes = await buildChannelAttributes({ user: userCreated, transaction: t, channelNames })
101 const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t) 101 const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t)
102 102
103 const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t) 103 const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t)
@@ -160,15 +160,28 @@ async function createApplicationActor (applicationId: number) {
160// --------------------------------------------------------------------------- 160// ---------------------------------------------------------------------------
161 161
162async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) { 162async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
163 const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id) 163 const verificationString = await Redis.Instance.setUserVerifyEmailVerificationString(user.id)
164 let url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString 164 let verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?userId=${user.id}&verificationString=${verificationString}`
165 165
166 if (isPendingEmail) url += '&isPendingEmail=true' 166 if (isPendingEmail) verifyEmailUrl += '&isPendingEmail=true'
167
168 const to = isPendingEmail
169 ? user.pendingEmail
170 : user.email
167 171
168 const email = isPendingEmail ? user.pendingEmail : user.email
169 const username = user.username 172 const username = user.username
170 173
171 Emailer.Instance.addVerifyEmailJob(username, email, url) 174 Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: false })
175}
176
177async function sendVerifyRegistrationEmail (registration: MRegistration) {
178 const verificationString = await Redis.Instance.setRegistrationVerifyEmailVerificationString(registration.id)
179 const verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?registrationId=${registration.id}&verificationString=${verificationString}`
180
181 const to = registration.email
182 const username = registration.username
183
184 Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: true })
172} 185}
173 186
174// --------------------------------------------------------------------------- 187// ---------------------------------------------------------------------------
@@ -232,7 +245,10 @@ export {
232 createApplicationActor, 245 createApplicationActor,
233 createUserAccountAndChannelAndPlaylist, 246 createUserAccountAndChannelAndPlaylist,
234 createLocalAccountWithoutKeys, 247 createLocalAccountWithoutKeys,
248
235 sendVerifyUserEmail, 249 sendVerifyUserEmail,
250 sendVerifyRegistrationEmail,
251
236 isAbleToUploadVideo, 252 isAbleToUploadVideo,
237 buildUser 253 buildUser
238} 254}
@@ -264,7 +280,13 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
264 return UserNotificationSettingModel.create(values, { transaction: t }) 280 return UserNotificationSettingModel.create(values, { transaction: t })
265} 281}
266 282
267async function buildChannelAttributes (user: MUser, transaction?: Transaction, channelNames?: ChannelNames) { 283async function buildChannelAttributes (options: {
284 user: MUser
285 transaction?: Transaction
286 channelNames?: ChannelNames
287}) {
288 const { user, transaction, channelNames } = options
289
268 if (channelNames) return channelNames 290 if (channelNames) return channelNames
269 291
270 const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction) 292 const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction)
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
index 02f160fe8..6eb865f7f 100644
--- a/server/lib/video-comment.ts
+++ b/server/lib/video-comment.ts
@@ -1,30 +1,41 @@
1import express from 'express'
1import { cloneDeep } from 'lodash' 2import { cloneDeep } from 'lodash'
2import * as Sequelize from 'sequelize' 3import * as Sequelize from 'sequelize'
3import express from 'express'
4import { logger } from '@server/helpers/logger' 4import { logger } from '@server/helpers/logger'
5import { sequelizeTypescript } from '@server/initializers/database' 5import { sequelizeTypescript } from '@server/initializers/database'
6import { ResultList } from '../../shared/models' 6import { ResultList } from '../../shared/models'
7import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model' 7import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
8import { VideoCommentModel } from '../models/video/video-comment' 8import { VideoCommentModel } from '../models/video/video-comment'
9import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models' 9import {
10 MAccountDefault,
11 MComment,
12 MCommentFormattable,
13 MCommentOwnerVideo,
14 MCommentOwnerVideoReply,
15 MVideoFullLight
16} from '../types/models'
10import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send' 17import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
11import { getLocalVideoCommentActivityPubUrl } from './activitypub/url' 18import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
12import { Hooks } from './plugins/hooks' 19import { Hooks } from './plugins/hooks'
13 20
14async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) { 21async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
15 const videoCommentInstanceBefore = cloneDeep(videoCommentInstance) 22 let videoCommentInstanceBefore: MCommentOwnerVideo
16 23
17 await sequelizeTypescript.transaction(async t => { 24 await sequelizeTypescript.transaction(async t => {
18 if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) { 25 const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
19 await sendDeleteVideoComment(videoCommentInstance, t) 26
27 videoCommentInstanceBefore = cloneDeep(comment)
28
29 if (comment.isOwned() || comment.Video.isOwned()) {
30 await sendDeleteVideoComment(comment, t)
20 } 31 }
21 32
22 videoCommentInstance.markAsDeleted() 33 comment.markAsDeleted()
23 34
24 await videoCommentInstance.save({ transaction: t }) 35 await comment.save({ transaction: t })
25 })
26 36
27 logger.info('Video comment %d deleted.', videoCommentInstance.id) 37 logger.info('Video comment %d deleted.', comment.id)
38 })
28 39
29 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res }) 40 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
30} 41}
@@ -64,7 +75,7 @@ async function createVideoComment (obj: {
64 return savedComment 75 return savedComment
65} 76}
66 77
67function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree { 78function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
68 // Comments are sorted by id ASC 79 // Comments are sorted by id ASC
69 const comments = resultList.data 80 const comments = resultList.data
70 81
diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts
index c43085d16..17aa29cdd 100644
--- a/server/lib/video-tokens-manager.ts
+++ b/server/lib/video-tokens-manager.ts
@@ -1,5 +1,7 @@
1import LRUCache from 'lru-cache' 1import LRUCache from 'lru-cache'
2import { LRU_CACHE } from '@server/initializers/constants' 2import { LRU_CACHE } from '@server/initializers/constants'
3import { MUserAccountUrl } from '@server/types/models'
4import { pick } from '@shared/core-utils'
3import { buildUUID } from '@shared/extra-utils' 5import { buildUUID } from '@shared/extra-utils'
4 6
5// --------------------------------------------------------------------------- 7// ---------------------------------------------------------------------------
@@ -10,19 +12,22 @@ class VideoTokensManager {
10 12
11 private static instance: VideoTokensManager 13 private static instance: VideoTokensManager
12 14
13 private readonly lruCache = new LRUCache<string, string>({ 15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({
14 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, 16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
15 ttl: LRU_CACHE.VIDEO_TOKENS.TTL 17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL
16 }) 18 })
17 19
18 private constructor () {} 20 private constructor () {}
19 21
20 create (videoUUID: string) { 22 create (options: {
23 user: MUserAccountUrl
24 videoUUID: string
25 }) {
21 const token = buildUUID() 26 const token = buildUUID()
22 27
23 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL) 28 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
24 29
25 this.lruCache.set(token, videoUUID) 30 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
26 31
27 return { token, expires } 32 return { token, expires }
28 } 33 }
@@ -34,7 +39,16 @@ class VideoTokensManager {
34 const value = this.lruCache.get(options.token) 39 const value = this.lruCache.get(options.token)
35 if (!value) return false 40 if (!value) return false
36 41
37 return value === options.videoUUID 42 return value.videoUUID === options.videoUUID
43 }
44
45 getUserFromToken (options: {
46 token: string
47 }) {
48 const value = this.lruCache.get(options.token)
49 if (!value) return undefined
50
51 return value.user
38 } 52 }
39 53
40 static get Instance () { 54 static get Instance () {
diff --git a/server/middlewares/sort.ts b/server/middlewares/sort.ts
index 458895898..77a532276 100644
--- a/server/middlewares/sort.ts
+++ b/server/middlewares/sort.ts
@@ -1,5 +1,4 @@
1import express from 'express' 1import express from 'express'
2import { SortType } from '../models/utils'
3 2
4const setDefaultSort = setDefaultSortFactory('-createdAt') 3const setDefaultSort = setDefaultSortFactory('-createdAt')
5const setDefaultVideosSort = setDefaultSortFactory('-publishedAt') 4const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
@@ -7,27 +6,7 @@ const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
7const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name') 6const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
8 7
9const setDefaultSearchSort = setDefaultSortFactory('-match') 8const setDefaultSearchSort = setDefaultSortFactory('-match')
10 9const setBlacklistSort = setDefaultSortFactory('-createdAt')
11function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
12 const newSort: SortType = { sortModel: undefined, sortValue: '' }
13
14 if (!req.query.sort) req.query.sort = '-createdAt'
15
16 // Set model we want to sort onto
17 if (req.query.sort === '-createdAt' || req.query.sort === 'createdAt' ||
18 req.query.sort === '-id' || req.query.sort === 'id') {
19 // If we want to sort onto the BlacklistedVideos relation, we won't specify it in the query parameter...
20 newSort.sortModel = undefined
21 } else {
22 newSort.sortModel = 'Video'
23 }
24
25 newSort.sortValue = req.query.sort
26
27 req.query.sort = newSort
28
29 return next()
30}
31 10
32// --------------------------------------------------------------------------- 11// ---------------------------------------------------------------------------
33 12
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index 3a7daa573..c2dbfadb7 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -29,6 +29,7 @@ const customConfigUpdateValidator = [
29 body('signup.enabled').isBoolean(), 29 body('signup.enabled').isBoolean(),
30 body('signup.limit').isInt(), 30 body('signup.limit').isInt(),
31 body('signup.requiresEmailVerification').isBoolean(), 31 body('signup.requiresEmailVerification').isBoolean(),
32 body('signup.requiresApproval').isBoolean(),
32 body('signup.minimumAge').isInt(), 33 body('signup.minimumAge').isInt(),
33 34
34 body('admin.email').isEmail(), 35 body('admin.email').isEmail(),
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts
index 9bc8887ff..1d0964667 100644
--- a/server/middlewares/validators/index.ts
+++ b/server/middlewares/validators/index.ts
@@ -21,8 +21,10 @@ export * from './server'
21export * from './sort' 21export * from './sort'
22export * from './static' 22export * from './static'
23export * from './themes' 23export * from './themes'
24export * from './user-email-verification'
24export * from './user-history' 25export * from './user-history'
25export * from './user-notifications' 26export * from './user-notifications'
27export * from './user-registrations'
26export * from './user-subscriptions' 28export * from './user-subscriptions'
27export * from './users' 29export * from './users'
28export * from './videos' 30export * from './videos'
diff --git a/server/middlewares/validators/shared/user-registrations.ts b/server/middlewares/validators/shared/user-registrations.ts
new file mode 100644
index 000000000..dbc7dda06
--- /dev/null
+++ b/server/middlewares/validators/shared/user-registrations.ts
@@ -0,0 +1,60 @@
1import express from 'express'
2import { UserRegistrationModel } from '@server/models/user/user-registration'
3import { MRegistration } from '@server/types/models'
4import { forceNumber, pick } from '@shared/core-utils'
5import { HttpStatusCode } from '@shared/models'
6
7function checkRegistrationIdExist (idArg: number | string, res: express.Response) {
8 const id = forceNumber(idArg)
9 return checkRegistrationExist(() => UserRegistrationModel.load(id), res)
10}
11
12function checkRegistrationEmailExist (email: string, res: express.Response, abortResponse = true) {
13 return checkRegistrationExist(() => UserRegistrationModel.loadByEmail(email), res, abortResponse)
14}
15
16async function checkRegistrationHandlesDoNotAlreadyExist (options: {
17 username: string
18 channelHandle: string
19 email: string
20 res: express.Response
21}) {
22 const { res } = options
23
24 const registration = await UserRegistrationModel.loadByEmailOrHandle(pick(options, [ 'username', 'email', 'channelHandle' ]))
25
26 if (registration) {
27 res.fail({
28 status: HttpStatusCode.CONFLICT_409,
29 message: 'Registration with this username, channel name or email already exists.'
30 })
31 return false
32 }
33
34 return true
35}
36
37async function checkRegistrationExist (finder: () => Promise<MRegistration>, res: express.Response, abortResponse = true) {
38 const registration = await finder()
39
40 if (!registration) {
41 if (abortResponse === true) {
42 res.fail({
43 status: HttpStatusCode.NOT_FOUND_404,
44 message: 'User not found'
45 })
46 }
47
48 return false
49 }
50
51 res.locals.userRegistration = registration
52 return true
53}
54
55export {
56 checkRegistrationIdExist,
57 checkRegistrationEmailExist,
58 checkRegistrationHandlesDoNotAlreadyExist,
59 checkRegistrationExist
60}
diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts
index b8f1436d3..030adc9f7 100644
--- a/server/middlewares/validators/shared/users.ts
+++ b/server/middlewares/validators/shared/users.ts
@@ -14,7 +14,7 @@ function checkUserEmailExist (email: string, res: express.Response, abortRespons
14 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) 14 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
15} 15}
16 16
17async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { 17async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) {
18 const user = await UserModel.loadByUsernameOrEmail(username, email) 18 const user = await UserModel.loadByUsernameOrEmail(username, email)
19 19
20 if (user) { 20 if (user) {
@@ -58,6 +58,6 @@ async function checkUserExist (finder: () => Promise<MUserDefault>, res: express
58export { 58export {
59 checkUserIdExist, 59 checkUserIdExist,
60 checkUserEmailExist, 60 checkUserEmailExist,
61 checkUserNameOrEmailDoesNotAlreadyExist, 61 checkUserNameOrEmailDoNotAlreadyExist,
62 checkUserExist 62 checkUserExist
63} 63}
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index ebbfc0a0a..0033a32ff 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -180,18 +180,16 @@ async function checkCanAccessVideoStaticFiles (options: {
180 return checkCanSeeVideo(options) 180 return checkCanSeeVideo(options)
181 } 181 }
182 182
183 if (!video.hasPrivateStaticPath()) return true
184
185 const videoFileToken = req.query.videoFileToken 183 const videoFileToken = req.query.videoFileToken
186 if (!videoFileToken) { 184 if (videoFileToken && VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
187 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 185 const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
188 return false
189 }
190 186
191 if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) { 187 res.locals.videoFileToken = { user }
192 return true 188 return true
193 } 189 }
194 190
191 if (!video.hasPrivateStaticPath()) return true
192
195 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 193 res.sendStatus(HttpStatusCode.FORBIDDEN_403)
196 return false 194 return false
197} 195}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 7d0639107..e6cc46317 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -1,9 +1,41 @@
1import express from 'express' 1import express from 'express'
2import { query } from 'express-validator' 2import { query } from 'express-validator'
3
4import { SORTABLE_COLUMNS } from '../../initializers/constants' 3import { SORTABLE_COLUMNS } from '../../initializers/constants'
5import { areValidationErrors } from './shared' 4import { areValidationErrors } from './shared'
6 5
6export const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
7export const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
8export const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
9export const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
10export const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
11export const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
12export const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
13export const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
14export const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
15export const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
16export const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
17export const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
18export const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
19export const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
20export const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
21export const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
22export const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
23export const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
24export const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
25export const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
26export const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
27export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
28export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
29export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
30export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
31
32export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
33export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
34
35export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS)
36
37// ---------------------------------------------------------------------------
38
7function checkSortFactory (columns: string[], tags: string[] = []) { 39function checkSortFactory (columns: string[], tags: string[] = []) {
8 return checkSort(createSortableColumns(columns), tags) 40 return checkSort(createSortableColumns(columns), tags)
9} 41}
@@ -27,64 +59,3 @@ function createSortableColumns (sortableColumns: string[]) {
27 59
28 return sortableColumns.concat(sortableColumnDesc) 60 return sortableColumns.concat(sortableColumnDesc)
29} 61}
30
31const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
32const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
33const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
34const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
35const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
36const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
37const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
38const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
39const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
40const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
41const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
42const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
43const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
44const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
45const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
46const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
47const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
48const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
49const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
50const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
51const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
52const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
53const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
54const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
55const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
56
57const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
58const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
59
60// ---------------------------------------------------------------------------
61
62export {
63 adminUsersSortValidator,
64 abusesSortValidator,
65 videoChannelsSortValidator,
66 videoImportsSortValidator,
67 videoCommentsValidator,
68 videosSearchSortValidator,
69 videosSortValidator,
70 blacklistSortValidator,
71 accountsSortValidator,
72 instanceFollowersSortValidator,
73 instanceFollowingSortValidator,
74 jobsSortValidator,
75 videoCommentThreadsSortValidator,
76 videoRatesSortValidator,
77 userSubscriptionsSortValidator,
78 availablePluginsSortValidator,
79 videoChannelsSearchSortValidator,
80 accountsBlocklistSortValidator,
81 serversBlocklistSortValidator,
82 userNotificationsSortValidator,
83 videoPlaylistsSortValidator,
84 videoRedundanciesSortValidator,
85 videoPlaylistsSearchSortValidator,
86 accountsFollowersSortValidator,
87 videoChannelsFollowersSortValidator,
88 videoChannelSyncsSortValidator,
89 pluginsSortValidator
90}
diff --git a/server/middlewares/validators/user-email-verification.ts b/server/middlewares/validators/user-email-verification.ts
new file mode 100644
index 000000000..74702a8f5
--- /dev/null
+++ b/server/middlewares/validators/user-email-verification.ts
@@ -0,0 +1,94 @@
1import express from 'express'
2import { body, param } from 'express-validator'
3import { toBooleanOrNull } from '@server/helpers/custom-validators/misc'
4import { HttpStatusCode } from '@shared/models'
5import { logger } from '../../helpers/logger'
6import { Redis } from '../../lib/redis'
7import { areValidationErrors, checkUserEmailExist, checkUserIdExist } from './shared'
8import { checkRegistrationEmailExist, checkRegistrationIdExist } from './shared/user-registrations'
9
10const usersAskSendVerifyEmailValidator = [
11 body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
12
13 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
14 if (areValidationErrors(req, res)) return
15
16 const [ userExists, registrationExists ] = await Promise.all([
17 checkUserEmailExist(req.body.email, res, false),
18 checkRegistrationEmailExist(req.body.email, res, false)
19 ])
20
21 if (!userExists && !registrationExists) {
22 logger.debug('User or registration with email %s does not exist (asking verify email).', req.body.email)
23 // Do not leak our emails
24 return res.status(HttpStatusCode.NO_CONTENT_204).end()
25 }
26
27 if (res.locals.user?.pluginAuth) {
28 return res.fail({
29 status: HttpStatusCode.CONFLICT_409,
30 message: 'Cannot ask verification email of a user that uses a plugin authentication.'
31 })
32 }
33
34 return next()
35 }
36]
37
38const usersVerifyEmailValidator = [
39 param('id')
40 .isInt().not().isEmpty().withMessage('Should have a valid id'),
41
42 body('verificationString')
43 .not().isEmpty().withMessage('Should have a valid verification string'),
44 body('isPendingEmail')
45 .optional()
46 .customSanitizer(toBooleanOrNull),
47
48 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
49 if (areValidationErrors(req, res)) return
50 if (!await checkUserIdExist(req.params.id, res)) return
51
52 const user = res.locals.user
53 const redisVerificationString = await Redis.Instance.getUserVerifyEmailLink(user.id)
54
55 if (redisVerificationString !== req.body.verificationString) {
56 return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
57 }
58
59 return next()
60 }
61]
62
63// ---------------------------------------------------------------------------
64
65const registrationVerifyEmailValidator = [
66 param('registrationId')
67 .isInt().not().isEmpty().withMessage('Should have a valid registrationId'),
68
69 body('verificationString')
70 .not().isEmpty().withMessage('Should have a valid verification string'),
71
72 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
73 if (areValidationErrors(req, res)) return
74 if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
75
76 const registration = res.locals.userRegistration
77 const redisVerificationString = await Redis.Instance.getRegistrationVerifyEmailLink(registration.id)
78
79 if (redisVerificationString !== req.body.verificationString) {
80 return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
81 }
82
83 return next()
84 }
85]
86
87// ---------------------------------------------------------------------------
88
89export {
90 usersAskSendVerifyEmailValidator,
91 usersVerifyEmailValidator,
92
93 registrationVerifyEmailValidator
94}
diff --git a/server/middlewares/validators/user-registrations.ts b/server/middlewares/validators/user-registrations.ts
new file mode 100644
index 000000000..fcf655a2c
--- /dev/null
+++ b/server/middlewares/validators/user-registrations.ts
@@ -0,0 +1,208 @@
1import express from 'express'
2import { body, param, query, ValidationChain } from 'express-validator'
3import { exists, isBooleanValid, isIdValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc'
4import { isRegistrationModerationResponseValid, isRegistrationReasonValid } from '@server/helpers/custom-validators/user-registration'
5import { CONFIG } from '@server/initializers/config'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState } from '@shared/models'
8import { isUserDisplayNameValid, isUserPasswordValid, isUserUsernameValid } from '../../helpers/custom-validators/users'
9import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
10import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../lib/signup'
11import { ActorModel } from '../../models/actor/actor'
12import { areValidationErrors, checkUserNameOrEmailDoNotAlreadyExist } from './shared'
13import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations'
14
15const usersDirectRegistrationValidator = usersCommonRegistrationValidatorFactory()
16
17const usersRequestRegistrationValidator = [
18 ...usersCommonRegistrationValidatorFactory([
19 body('registrationReason')
20 .custom(isRegistrationReasonValid)
21 ]),
22
23 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
24 const body: UserRegistrationRequest = req.body
25
26 if (CONFIG.SIGNUP.REQUIRES_APPROVAL !== true) {
27 return res.fail({
28 status: HttpStatusCode.BAD_REQUEST_400,
29 message: 'Signup approval is not enabled on this instance'
30 })
31 }
32
33 const options = { username: body.username, email: body.email, channelHandle: body.channel?.name, res }
34 if (!await checkRegistrationHandlesDoNotAlreadyExist(options)) return
35
36 return next()
37 }
38]
39
40// ---------------------------------------------------------------------------
41
42function ensureUserRegistrationAllowedFactory (signupMode: SignupMode) {
43 return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
44 const allowedParams = {
45 body: req.body,
46 ip: req.ip,
47 signupMode
48 }
49
50 const allowedResult = await Hooks.wrapPromiseFun(
51 isSignupAllowed,
52 allowedParams,
53
54 signupMode === 'direct-registration'
55 ? 'filter:api.user.signup.allowed.result'
56 : 'filter:api.user.request-signup.allowed.result'
57 )
58
59 if (allowedResult.allowed === false) {
60 return res.fail({
61 status: HttpStatusCode.FORBIDDEN_403,
62 message: allowedResult.errorMessage || 'User registration is not enabled, user limit is reached or registration requires approval.'
63 })
64 }
65
66 return next()
67 }
68}
69
70const ensureUserRegistrationAllowedForIP = [
71 (req: express.Request, res: express.Response, next: express.NextFunction) => {
72 const allowed = isSignupAllowedForCurrentIP(req.ip)
73
74 if (allowed === false) {
75 return res.fail({
76 status: HttpStatusCode.FORBIDDEN_403,
77 message: 'You are not on a network authorized for registration.'
78 })
79 }
80
81 return next()
82 }
83]
84
85// ---------------------------------------------------------------------------
86
87const acceptOrRejectRegistrationValidator = [
88 param('registrationId')
89 .custom(isIdValid),
90
91 body('moderationResponse')
92 .custom(isRegistrationModerationResponseValid),
93
94 body('preventEmailDelivery')
95 .optional()
96 .customSanitizer(toBooleanOrNull)
97 .custom(isBooleanValid).withMessage('Should have preventEmailDelivery boolean'),
98
99 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
100 if (areValidationErrors(req, res)) return
101 if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
102
103 if (res.locals.userRegistration.state !== UserRegistrationState.PENDING) {
104 return res.fail({
105 status: HttpStatusCode.CONFLICT_409,
106 message: 'This registration is already accepted or rejected.'
107 })
108 }
109
110 return next()
111 }
112]
113
114// ---------------------------------------------------------------------------
115
116const getRegistrationValidator = [
117 param('registrationId')
118 .custom(isIdValid),
119
120 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
121 if (areValidationErrors(req, res)) return
122 if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
123
124 return next()
125 }
126]
127
128// ---------------------------------------------------------------------------
129
130const listRegistrationsValidator = [
131 query('search')
132 .optional()
133 .custom(exists),
134
135 (req: express.Request, res: express.Response, next: express.NextFunction) => {
136 if (areValidationErrors(req, res)) return
137
138 return next()
139 }
140]
141
142// ---------------------------------------------------------------------------
143
144export {
145 usersDirectRegistrationValidator,
146 usersRequestRegistrationValidator,
147
148 ensureUserRegistrationAllowedFactory,
149 ensureUserRegistrationAllowedForIP,
150
151 getRegistrationValidator,
152 listRegistrationsValidator,
153
154 acceptOrRejectRegistrationValidator
155}
156
157// ---------------------------------------------------------------------------
158
159function usersCommonRegistrationValidatorFactory (additionalValidationChain: ValidationChain[] = []) {
160 return [
161 body('username')
162 .custom(isUserUsernameValid),
163 body('password')
164 .custom(isUserPasswordValid),
165 body('email')
166 .isEmail(),
167 body('displayName')
168 .optional()
169 .custom(isUserDisplayNameValid),
170
171 body('channel.name')
172 .optional()
173 .custom(isVideoChannelUsernameValid),
174 body('channel.displayName')
175 .optional()
176 .custom(isVideoChannelDisplayNameValid),
177
178 ...additionalValidationChain,
179
180 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
181 if (areValidationErrors(req, res, { omitBodyLog: true })) return
182
183 const body: UserRegister | UserRegistrationRequest = req.body
184
185 if (!await checkUserNameOrEmailDoNotAlreadyExist(body.username, body.email, res)) return
186
187 if (body.channel) {
188 if (!body.channel.name || !body.channel.displayName) {
189 return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
190 }
191
192 if (body.channel.name === body.username) {
193 return res.fail({ message: 'Channel name cannot be the same as user username.' })
194 }
195
196 const existing = await ActorModel.loadLocalByName(body.channel.name)
197 if (existing) {
198 return res.fail({
199 status: HttpStatusCode.CONFLICT_409,
200 message: `Channel with name ${body.channel.name} already exists.`
201 })
202 }
203 }
204
205 return next()
206 }
207 ]
208}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 64bd9ca70..f7033f44a 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -1,8 +1,7 @@
1import express from 'express' 1import express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { forceNumber } from '@shared/core-utils' 3import { forceNumber } from '@shared/core-utils'
5import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' 4import { HttpStatusCode, UserRight, UserRole } from '@shared/models'
6import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 5import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
7import { isThemeNameValid } from '../../helpers/custom-validators/plugins' 6import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
8import { 7import {
@@ -24,17 +23,16 @@ import {
24 isUserVideoQuotaValid, 23 isUserVideoQuotaValid,
25 isUserVideosHistoryEnabledValid 24 isUserVideosHistoryEnabledValid
26} from '../../helpers/custom-validators/users' 25} from '../../helpers/custom-validators/users'
27import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels' 26import { isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
28import { logger } from '../../helpers/logger' 27import { logger } from '../../helpers/logger'
29import { isThemeRegistered } from '../../lib/plugins/theme-utils' 28import { isThemeRegistered } from '../../lib/plugins/theme-utils'
30import { Redis } from '../../lib/redis' 29import { Redis } from '../../lib/redis'
31import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
32import { ActorModel } from '../../models/actor/actor' 30import { ActorModel } from '../../models/actor/actor'
33import { 31import {
34 areValidationErrors, 32 areValidationErrors,
35 checkUserEmailExist, 33 checkUserEmailExist,
36 checkUserIdExist, 34 checkUserIdExist,
37 checkUserNameOrEmailDoesNotAlreadyExist, 35 checkUserNameOrEmailDoNotAlreadyExist,
38 doesVideoChannelIdExist, 36 doesVideoChannelIdExist,
39 doesVideoExist, 37 doesVideoExist,
40 isValidVideoIdParam 38 isValidVideoIdParam
@@ -81,7 +79,7 @@ const usersAddValidator = [
81 79
82 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 80 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
83 if (areValidationErrors(req, res, { omitBodyLog: true })) return 81 if (areValidationErrors(req, res, { omitBodyLog: true })) return
84 if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return 82 if (!await checkUserNameOrEmailDoNotAlreadyExist(req.body.username, req.body.email, res)) return
85 83
86 const authUser = res.locals.oauth.token.User 84 const authUser = res.locals.oauth.token.User
87 if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) { 85 if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) {
@@ -109,51 +107,6 @@ const usersAddValidator = [
109 } 107 }
110] 108]
111 109
112const usersRegisterValidator = [
113 body('username')
114 .custom(isUserUsernameValid),
115 body('password')
116 .custom(isUserPasswordValid),
117 body('email')
118 .isEmail(),
119 body('displayName')
120 .optional()
121 .custom(isUserDisplayNameValid),
122
123 body('channel.name')
124 .optional()
125 .custom(isVideoChannelUsernameValid),
126 body('channel.displayName')
127 .optional()
128 .custom(isVideoChannelDisplayNameValid),
129
130 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
131 if (areValidationErrors(req, res, { omitBodyLog: true })) return
132 if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return
133
134 const body: UserRegister = req.body
135 if (body.channel) {
136 if (!body.channel.name || !body.channel.displayName) {
137 return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
138 }
139
140 if (body.channel.name === body.username) {
141 return res.fail({ message: 'Channel name cannot be the same as user username.' })
142 }
143
144 const existing = await ActorModel.loadLocalByName(body.channel.name)
145 if (existing) {
146 return res.fail({
147 status: HttpStatusCode.CONFLICT_409,
148 message: `Channel with name ${body.channel.name} already exists.`
149 })
150 }
151 }
152
153 return next()
154 }
155]
156
157const usersRemoveValidator = [ 110const usersRemoveValidator = [
158 param('id') 111 param('id')
159 .custom(isIdValid), 112 .custom(isIdValid),
@@ -365,45 +318,6 @@ const usersVideosValidator = [
365 } 318 }
366] 319]
367 320
368const ensureUserRegistrationAllowed = [
369 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
370 const allowedParams = {
371 body: req.body,
372 ip: req.ip
373 }
374
375 const allowedResult = await Hooks.wrapPromiseFun(
376 isSignupAllowed,
377 allowedParams,
378 'filter:api.user.signup.allowed.result'
379 )
380
381 if (allowedResult.allowed === false) {
382 return res.fail({
383 status: HttpStatusCode.FORBIDDEN_403,
384 message: allowedResult.errorMessage || 'User registration is not enabled or user limit is reached.'
385 })
386 }
387
388 return next()
389 }
390]
391
392const ensureUserRegistrationAllowedForIP = [
393 (req: express.Request, res: express.Response, next: express.NextFunction) => {
394 const allowed = isSignupAllowedForCurrentIP(req.ip)
395
396 if (allowed === false) {
397 return res.fail({
398 status: HttpStatusCode.FORBIDDEN_403,
399 message: 'You are not on a network authorized for registration.'
400 })
401 }
402
403 return next()
404 }
405]
406
407const usersAskResetPasswordValidator = [ 321const usersAskResetPasswordValidator = [
408 body('email') 322 body('email')
409 .isEmail(), 323 .isEmail(),
@@ -455,58 +369,6 @@ const usersResetPasswordValidator = [
455 } 369 }
456] 370]
457 371
458const usersAskSendVerifyEmailValidator = [
459 body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
460
461 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
462 if (areValidationErrors(req, res)) return
463
464 const exists = await checkUserEmailExist(req.body.email, res, false)
465 if (!exists) {
466 logger.debug('User with email %s does not exist (asking verify email).', req.body.email)
467 // Do not leak our emails
468 return res.status(HttpStatusCode.NO_CONTENT_204).end()
469 }
470
471 if (res.locals.user.pluginAuth) {
472 return res.fail({
473 status: HttpStatusCode.CONFLICT_409,
474 message: 'Cannot ask verification email of a user that uses a plugin authentication.'
475 })
476 }
477
478 return next()
479 }
480]
481
482const usersVerifyEmailValidator = [
483 param('id')
484 .isInt().not().isEmpty().withMessage('Should have a valid id'),
485
486 body('verificationString')
487 .not().isEmpty().withMessage('Should have a valid verification string'),
488 body('isPendingEmail')
489 .optional()
490 .customSanitizer(toBooleanOrNull),
491
492 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
493 if (areValidationErrors(req, res)) return
494 if (!await checkUserIdExist(req.params.id, res)) return
495
496 const user = res.locals.user
497 const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id)
498
499 if (redisVerificationString !== req.body.verificationString) {
500 return res.fail({
501 status: HttpStatusCode.FORBIDDEN_403,
502 message: 'Invalid verification string.'
503 })
504 }
505
506 return next()
507 }
508]
509
510const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => { 372const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => {
511 return [ 373 return [
512 body('currentPassword').optional().custom(exists), 374 body('currentPassword').optional().custom(exists),
@@ -603,21 +465,16 @@ export {
603 usersListValidator, 465 usersListValidator,
604 usersAddValidator, 466 usersAddValidator,
605 deleteMeValidator, 467 deleteMeValidator,
606 usersRegisterValidator,
607 usersBlockingValidator, 468 usersBlockingValidator,
608 usersRemoveValidator, 469 usersRemoveValidator,
609 usersUpdateValidator, 470 usersUpdateValidator,
610 usersUpdateMeValidator, 471 usersUpdateMeValidator,
611 usersVideoRatingValidator, 472 usersVideoRatingValidator,
612 usersCheckCurrentPasswordFactory, 473 usersCheckCurrentPasswordFactory,
613 ensureUserRegistrationAllowed,
614 ensureUserRegistrationAllowedForIP,
615 usersGetValidator, 474 usersGetValidator,
616 usersVideosValidator, 475 usersVideosValidator,
617 usersAskResetPasswordValidator, 476 usersAskResetPasswordValidator,
618 usersResetPasswordValidator, 477 usersResetPasswordValidator,
619 usersAskSendVerifyEmailValidator,
620 usersVerifyEmailValidator,
621 userAutocompleteValidator, 478 userAutocompleteValidator,
622 ensureAuthUserOwnsAccountValidator, 479 ensureAuthUserOwnsAccountValidator,
623 ensureCanModerateUser, 480 ensureCanModerateUser,
diff --git a/server/models/abuse/abuse-message.ts b/server/models/abuse/abuse-message.ts
index 20008768b..14a5bffa2 100644
--- a/server/models/abuse/abuse-message.ts
+++ b/server/models/abuse/abuse-message.ts
@@ -5,7 +5,7 @@ import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
5import { AbuseMessage } from '@shared/models' 5import { AbuseMessage } from '@shared/models'
6import { AttributesOnly } from '@shared/typescript-utils' 6import { AttributesOnly } from '@shared/typescript-utils'
7import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' 7import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
8import { getSort, throwIfNotValid } from '../utils' 8import { getSort, throwIfNotValid } from '../shared'
9import { AbuseModel } from './abuse' 9import { AbuseModel } from './abuse'
10 10
11@Table({ 11@Table({
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
index 4c6a96a86..4ce40bf2f 100644
--- a/server/models/abuse/abuse.ts
+++ b/server/models/abuse/abuse.ts
@@ -34,13 +34,13 @@ import { AttributesOnly } from '@shared/typescript-utils'
34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants' 34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models' 35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' 36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
37import { getSort, throwIfNotValid } from '../utils' 37import { getSort, throwIfNotValid } from '../shared'
38import { ThumbnailModel } from '../video/thumbnail' 38import { ThumbnailModel } from '../video/thumbnail'
39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video' 39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
40import { VideoBlacklistModel } from '../video/video-blacklist' 40import { VideoBlacklistModel } from '../video/video-blacklist'
41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel' 41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment' 42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder' 43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './sql/abuse-query-builder'
44import { VideoAbuseModel } from './video-abuse' 44import { VideoAbuseModel } from './video-abuse'
45import { VideoCommentAbuseModel } from './video-comment-abuse' 45import { VideoCommentAbuseModel } from './video-comment-abuse'
46 46
diff --git a/server/models/abuse/abuse-query-builder.ts b/server/models/abuse/sql/abuse-query-builder.ts
index 74f4542e5..282d4541a 100644
--- a/server/models/abuse/abuse-query-builder.ts
+++ b/server/models/abuse/sql/abuse-query-builder.ts
@@ -2,7 +2,7 @@
2import { exists } from '@server/helpers/custom-validators/misc' 2import { exists } from '@server/helpers/custom-validators/misc'
3import { forceNumber } from '@shared/core-utils' 3import { forceNumber } from '@shared/core-utils'
4import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models' 4import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
5import { buildBlockedAccountSQL, buildDirectionAndField } from '../utils' 5import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared'
6 6
7export type BuildAbusesQueryOptions = { 7export type BuildAbusesQueryOptions = {
8 start: number 8 start: number
@@ -157,7 +157,7 @@ function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' |
157} 157}
158 158
159function buildAbuseOrder (value: string) { 159function buildAbuseOrder (value: string) {
160 const { direction, field } = buildDirectionAndField(value) 160 const { direction, field } = buildSortDirectionAndField(value)
161 161
162 return `ORDER BY "abuse"."${field}" ${direction}` 162 return `ORDER BY "abuse"."${field}" ${direction}`
163} 163}
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index 377249b38..f6212ff6e 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -6,7 +6,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountBlock } from '../../../shared/models' 6import { AccountBlock } from '../../../shared/models'
7import { ActorModel } from '../actor/actor' 7import { ActorModel } from '../actor/actor'
8import { ServerModel } from '../server/server' 8import { ServerModel } from '../server/server'
9import { createSafeIn, getSort, searchAttribute } from '../utils' 9import { createSafeIn, getSort, searchAttribute } from '../shared'
10import { AccountModel } from './account' 10import { AccountModel } from './account'
11 11
12@Table({ 12@Table({
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index 7afc907da..9e7ef4394 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -11,7 +11,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
12import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants' 12import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
13import { ActorModel } from '../actor/actor' 13import { ActorModel } from '../actor/actor'
14import { getSort, throwIfNotValid } from '../utils' 14import { getSort, throwIfNotValid } from '../shared'
15import { VideoModel } from '../video/video' 15import { VideoModel } from '../video/video'
16import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel' 16import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
17import { AccountModel } from './account' 17import { AccountModel } from './account'
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 8a7dfba94..dc989417b 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -16,7 +16,7 @@ import {
16 Table, 16 Table,
17 UpdatedAt 17 UpdatedAt
18} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { ModelCache } from '@server/models/model-cache' 19import { ModelCache } from '@server/models/shared/model-cache'
20import { AttributesOnly } from '@shared/typescript-utils' 20import { AttributesOnly } from '@shared/typescript-utils'
21import { Account, AccountSummary } from '../../../shared/models/actors' 21import { Account, AccountSummary } from '../../../shared/models/actors'
22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' 22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
@@ -38,7 +38,7 @@ import { ApplicationModel } from '../application/application'
38import { ServerModel } from '../server/server' 38import { ServerModel } from '../server/server'
39import { ServerBlocklistModel } from '../server/server-blocklist' 39import { ServerBlocklistModel } from '../server/server-blocklist'
40import { UserModel } from '../user/user' 40import { UserModel } from '../user/user'
41import { getSort, throwIfNotValid } from '../utils' 41import { buildSQLAttributes, getSort, throwIfNotValid } from '../shared'
42import { VideoModel } from '../video/video' 42import { VideoModel } from '../video/video'
43import { VideoChannelModel } from '../video/video-channel' 43import { VideoChannelModel } from '../video/video-channel'
44import { VideoCommentModel } from '../video/video-comment' 44import { VideoCommentModel } from '../video/video-comment'
@@ -251,6 +251,18 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
251 return undefined 251 return undefined
252 } 252 }
253 253
254 // ---------------------------------------------------------------------------
255
256 static getSQLAttributes (tableName: string, aliasPrefix = '') {
257 return buildSQLAttributes({
258 model: this,
259 tableName,
260 aliasPrefix
261 })
262 }
263
264 // ---------------------------------------------------------------------------
265
254 static load (id: number, transaction?: Transaction): Promise<MAccountDefault> { 266 static load (id: number, transaction?: Transaction): Promise<MAccountDefault> {
255 return AccountModel.findByPk(id, { transaction }) 267 return AccountModel.findByPk(id, { transaction })
256 } 268 }
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
index 9615229dd..32e5d78b0 100644
--- a/server/models/actor/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -38,7 +38,7 @@ import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAM
38import { AccountModel } from '../account/account' 38import { AccountModel } from '../account/account'
39import { ServerModel } from '../server/server' 39import { ServerModel } from '../server/server'
40import { doesExist } from '../shared/query' 40import { doesExist } from '../shared/query'
41import { createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../utils' 41import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared'
42import { VideoChannelModel } from '../video/video-channel' 42import { VideoChannelModel } from '../video/video-channel'
43import { ActorModel, unusedActorAttributesForAPI } from './actor' 43import { ActorModel, unusedActorAttributesForAPI } from './actor'
44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder' 44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder'
@@ -140,6 +140,18 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
140 }) 140 })
141 } 141 }
142 142
143 // ---------------------------------------------------------------------------
144
145 static getSQLAttributes (tableName: string, aliasPrefix = '') {
146 return buildSQLAttributes({
147 model: this,
148 tableName,
149 aliasPrefix
150 })
151 }
152
153 // ---------------------------------------------------------------------------
154
143 /* 155 /*
144 * @deprecated Use `findOrCreateCustom` instead 156 * @deprecated Use `findOrCreateCustom` instead
145 */ 157 */
@@ -213,7 +225,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
213 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` + 225 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
214 `LIMIT 1` 226 `LIMIT 1`
215 227
216 return doesExist(query, { actorId, followerActorId }) 228 return doesExist(this.sequelize, query, { actorId, followerActorId })
217 } 229 }
218 230
219 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> { 231 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts
index f2b3b2f4b..9c34a0101 100644
--- a/server/models/actor/actor-image.ts
+++ b/server/models/actor/actor-image.ts
@@ -22,7 +22,7 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
22import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
23import { CONFIG } from '../../initializers/config' 23import { CONFIG } from '../../initializers/config'
24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants' 24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
25import { throwIfNotValid } from '../utils' 25import { buildSQLAttributes, throwIfNotValid } from '../shared'
26import { ActorModel } from './actor' 26import { ActorModel } from './actor'
27 27
28@Table({ 28@Table({
@@ -94,6 +94,18 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err })) 94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err }))
95 } 95 }
96 96
97 // ---------------------------------------------------------------------------
98
99 static getSQLAttributes (tableName: string, aliasPrefix = '') {
100 return buildSQLAttributes({
101 model: this,
102 tableName,
103 aliasPrefix
104 })
105 }
106
107 // ---------------------------------------------------------------------------
108
97 static loadByName (filename: string) { 109 static loadByName (filename: string) {
98 const query = { 110 const query = {
99 where: { 111 where: {
diff --git a/server/models/actor/actor.ts b/server/models/actor/actor.ts
index d7afa727d..1432e8757 100644
--- a/server/models/actor/actor.ts
+++ b/server/models/actor/actor.ts
@@ -17,7 +17,7 @@ import {
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { activityPubContextify } from '@server/lib/activitypub/context' 18import { activityPubContextify } from '@server/lib/activitypub/context'
19import { getBiggestActorImage } from '@server/lib/actor-image' 19import { getBiggestActorImage } from '@server/lib/actor-image'
20import { ModelCache } from '@server/models/model-cache' 20import { ModelCache } from '@server/models/shared/model-cache'
21import { forceNumber, getLowercaseExtension } from '@shared/core-utils' 21import { forceNumber, getLowercaseExtension } from '@shared/core-utils'
22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models' 22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
23import { AttributesOnly } from '@shared/typescript-utils' 23import { AttributesOnly } from '@shared/typescript-utils'
@@ -55,7 +55,7 @@ import {
55import { AccountModel } from '../account/account' 55import { AccountModel } from '../account/account'
56import { getServerActor } from '../application/application' 56import { getServerActor } from '../application/application'
57import { ServerModel } from '../server/server' 57import { ServerModel } from '../server/server'
58import { isOutdated, throwIfNotValid } from '../utils' 58import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared'
59import { VideoModel } from '../video/video' 59import { VideoModel } from '../video/video'
60import { VideoChannelModel } from '../video/video-channel' 60import { VideoChannelModel } from '../video/video-channel'
61import { ActorFollowModel } from './actor-follow' 61import { ActorFollowModel } from './actor-follow'
@@ -65,7 +65,7 @@ enum ScopeNames {
65 FULL = 'FULL' 65 FULL = 'FULL'
66} 66}
67 67
68export const unusedActorAttributesForAPI = [ 68export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] = [
69 'publicKey', 69 'publicKey',
70 'privateKey', 70 'privateKey',
71 'inboxUrl', 71 'inboxUrl',
@@ -306,6 +306,27 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
306 }) 306 })
307 VideoChannel: VideoChannelModel 307 VideoChannel: VideoChannelModel
308 308
309 // ---------------------------------------------------------------------------
310
311 static getSQLAttributes (tableName: string, aliasPrefix = '') {
312 return buildSQLAttributes({
313 model: this,
314 tableName,
315 aliasPrefix
316 })
317 }
318
319 static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
320 return buildSQLAttributes({
321 model: this,
322 tableName,
323 aliasPrefix,
324 excludeAttributes: unusedActorAttributesForAPI
325 })
326 }
327
328 // ---------------------------------------------------------------------------
329
309 static async load (id: number): Promise<MActor> { 330 static async load (id: number): Promise<MActor> {
310 const actorServer = await getServerActor() 331 const actorServer = await getServerActor()
311 if (id === actorServer.id) return actorServer 332 if (id === actorServer.id) return actorServer
diff --git a/server/models/actor/sql/instance-list-followers-query-builder.ts b/server/models/actor/sql/instance-list-followers-query-builder.ts
index 4a17a8f11..34ce29b5d 100644
--- a/server/models/actor/sql/instance-list-followers-query-builder.ts
+++ b/server/models/actor/sql/instance-list-followers-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared' 2import { ModelBuilder } from '@server/models/shared'
3import { parseRowCountResult } from '@server/models/utils'
4import { MActorFollowActorsDefault } from '@server/types/models' 3import { MActorFollowActorsDefault } from '@server/types/models'
5import { ActivityPubActorType, FollowState } from '@shared/models' 4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' 6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7 7
8export interface ListFollowersOptions { 8export interface ListFollowersOptions {
diff --git a/server/models/actor/sql/instance-list-following-query-builder.ts b/server/models/actor/sql/instance-list-following-query-builder.ts
index 880170b85..77b4e3dce 100644
--- a/server/models/actor/sql/instance-list-following-query-builder.ts
+++ b/server/models/actor/sql/instance-list-following-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared' 2import { ModelBuilder } from '@server/models/shared'
3import { parseRowCountResult } from '@server/models/utils'
4import { MActorFollowActorsDefault } from '@server/types/models' 3import { MActorFollowActorsDefault } from '@server/types/models'
5import { ActivityPubActorType, FollowState } from '@shared/models' 4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' 6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7 7
8export interface ListFollowingOptions { 8export interface ListFollowingOptions {
diff --git a/server/models/actor/sql/shared/actor-follow-table-attributes.ts b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
index 156b37d44..7dd908ece 100644
--- a/server/models/actor/sql/shared/actor-follow-table-attributes.ts
+++ b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
@@ -1,62 +1,31 @@
1import { logger } from '@server/helpers/logger'
2import { Memoize } from '@server/helpers/memoize'
3import { ServerModel } from '@server/models/server/server'
4import { ActorModel } from '../../actor'
5import { ActorFollowModel } from '../../actor-follow'
6import { ActorImageModel } from '../../actor-image'
7
1export class ActorFollowTableAttributes { 8export class ActorFollowTableAttributes {
2 9
10 @Memoize()
3 getFollowAttributes () { 11 getFollowAttributes () {
4 return [ 12 logger.error('coucou')
5 '"ActorFollowModel"."id"', 13
6 '"ActorFollowModel"."state"', 14 return ActorFollowModel.getSQLAttributes('ActorFollowModel').join(', ')
7 '"ActorFollowModel"."score"',
8 '"ActorFollowModel"."url"',
9 '"ActorFollowModel"."actorId"',
10 '"ActorFollowModel"."targetActorId"',
11 '"ActorFollowModel"."createdAt"',
12 '"ActorFollowModel"."updatedAt"'
13 ].join(', ')
14 } 15 }
15 16
17 @Memoize()
16 getActorAttributes (actorTableName: string) { 18 getActorAttributes (actorTableName: string) {
17 return [ 19 return ActorModel.getSQLAttributes(actorTableName, `${actorTableName}.`).join(', ')
18 `"${actorTableName}"."id" AS "${actorTableName}.id"`,
19 `"${actorTableName}"."type" AS "${actorTableName}.type"`,
20 `"${actorTableName}"."preferredUsername" AS "${actorTableName}.preferredUsername"`,
21 `"${actorTableName}"."url" AS "${actorTableName}.url"`,
22 `"${actorTableName}"."publicKey" AS "${actorTableName}.publicKey"`,
23 `"${actorTableName}"."privateKey" AS "${actorTableName}.privateKey"`,
24 `"${actorTableName}"."followersCount" AS "${actorTableName}.followersCount"`,
25 `"${actorTableName}"."followingCount" AS "${actorTableName}.followingCount"`,
26 `"${actorTableName}"."inboxUrl" AS "${actorTableName}.inboxUrl"`,
27 `"${actorTableName}"."outboxUrl" AS "${actorTableName}.outboxUrl"`,
28 `"${actorTableName}"."sharedInboxUrl" AS "${actorTableName}.sharedInboxUrl"`,
29 `"${actorTableName}"."followersUrl" AS "${actorTableName}.followersUrl"`,
30 `"${actorTableName}"."followingUrl" AS "${actorTableName}.followingUrl"`,
31 `"${actorTableName}"."remoteCreatedAt" AS "${actorTableName}.remoteCreatedAt"`,
32 `"${actorTableName}"."serverId" AS "${actorTableName}.serverId"`,
33 `"${actorTableName}"."createdAt" AS "${actorTableName}.createdAt"`,
34 `"${actorTableName}"."updatedAt" AS "${actorTableName}.updatedAt"`
35 ].join(', ')
36 } 20 }
37 21
22 @Memoize()
38 getServerAttributes (actorTableName: string) { 23 getServerAttributes (actorTableName: string) {
39 return [ 24 return ServerModel.getSQLAttributes(`${actorTableName}->Server`, `${actorTableName}.Server.`).join(', ')
40 `"${actorTableName}->Server"."id" AS "${actorTableName}.Server.id"`,
41 `"${actorTableName}->Server"."host" AS "${actorTableName}.Server.host"`,
42 `"${actorTableName}->Server"."redundancyAllowed" AS "${actorTableName}.Server.redundancyAllowed"`,
43 `"${actorTableName}->Server"."createdAt" AS "${actorTableName}.Server.createdAt"`,
44 `"${actorTableName}->Server"."updatedAt" AS "${actorTableName}.Server.updatedAt"`
45 ].join(', ')
46 } 25 }
47 26
27 @Memoize()
48 getAvatarAttributes (actorTableName: string) { 28 getAvatarAttributes (actorTableName: string) {
49 return [ 29 return ActorImageModel.getSQLAttributes(`${actorTableName}->Avatars`, `${actorTableName}.Avatars.`).join(', ')
50 `"${actorTableName}->Avatars"."id" AS "${actorTableName}.Avatars.id"`,
51 `"${actorTableName}->Avatars"."filename" AS "${actorTableName}.Avatars.filename"`,
52 `"${actorTableName}->Avatars"."height" AS "${actorTableName}.Avatars.height"`,
53 `"${actorTableName}->Avatars"."width" AS "${actorTableName}.Avatars.width"`,
54 `"${actorTableName}->Avatars"."fileUrl" AS "${actorTableName}.Avatars.fileUrl"`,
55 `"${actorTableName}->Avatars"."onDisk" AS "${actorTableName}.Avatars.onDisk"`,
56 `"${actorTableName}->Avatars"."type" AS "${actorTableName}.Avatars.type"`,
57 `"${actorTableName}->Avatars"."actorId" AS "${actorTableName}.Avatars.actorId"`,
58 `"${actorTableName}->Avatars"."createdAt" AS "${actorTableName}.Avatars.createdAt"`,
59 `"${actorTableName}->Avatars"."updatedAt" AS "${actorTableName}.Avatars.updatedAt"`
60 ].join(', ')
61 } 30 }
62} 31}
diff --git a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
index 1d70fbe70..d9593e48b 100644
--- a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
+++ b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
@@ -1,7 +1,7 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery } from '@server/models/shared' 2import { AbstractRunQuery } from '@server/models/shared'
3import { getInstanceFollowsSort } from '@server/models/utils'
4import { ActorImageType } from '@shared/models' 3import { ActorImageType } from '@shared/models'
4import { getInstanceFollowsSort } from '../../../shared'
5import { ActorFollowTableAttributes } from './actor-follow-table-attributes' 5import { ActorFollowTableAttributes } from './actor-follow-table-attributes'
6 6
7type BaseOptions = { 7type BaseOptions = {
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 15909d5f3..c2a72b71f 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -34,7 +34,7 @@ import { CONFIG } from '../../initializers/config'
34import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' 34import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
35import { ActorModel } from '../actor/actor' 35import { ActorModel } from '../actor/actor'
36import { ServerModel } from '../server/server' 36import { ServerModel } from '../server/server'
37import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' 37import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../shared'
38import { ScheduleVideoUpdateModel } from '../video/schedule-video-update' 38import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
39import { VideoModel } from '../video/video' 39import { VideoModel } from '../video/video'
40import { VideoChannelModel } from '../video/video-channel' 40import { VideoChannelModel } from '../video/video-channel'
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
index 71c205ffa..9948c9f7a 100644
--- a/server/models/server/plugin.ts
+++ b/server/models/server/plugin.ts
@@ -11,7 +11,7 @@ import {
11 isPluginStableVersionValid, 11 isPluginStableVersionValid,
12 isPluginTypeValid 12 isPluginTypeValid
13} from '../../helpers/custom-validators/plugins' 13} from '../../helpers/custom-validators/plugins'
14import { getSort, throwIfNotValid } from '../utils' 14import { getSort, throwIfNotValid } from '../shared'
15 15
16@DefaultScope(() => ({ 16@DefaultScope(() => ({
17 attributes: { 17 attributes: {
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 9752dfbc3..3d755fe4a 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -4,7 +4,7 @@ import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormat
4import { ServerBlock } from '@shared/models' 4import { ServerBlock } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils' 5import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
7import { createSafeIn, getSort, searchAttribute } from '../utils' 7import { createSafeIn, getSort, searchAttribute } from '../shared'
8import { ServerModel } from './server' 8import { ServerModel } from './server'
9 9
10enum ScopeNames { 10enum ScopeNames {
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
index ef42de090..a5e05f460 100644
--- a/server/models/server/server.ts
+++ b/server/models/server/server.ts
@@ -4,7 +4,7 @@ import { MServer, MServerFormattable } from '@server/types/models/server'
4import { AttributesOnly } from '@shared/typescript-utils' 4import { AttributesOnly } from '@shared/typescript-utils'
5import { isHostValid } from '../../helpers/custom-validators/servers' 5import { isHostValid } from '../../helpers/custom-validators/servers'
6import { ActorModel } from '../actor/actor' 6import { ActorModel } from '../actor/actor'
7import { throwIfNotValid } from '../utils' 7import { buildSQLAttributes, throwIfNotValid } from '../shared'
8import { ServerBlocklistModel } from './server-blocklist' 8import { ServerBlocklistModel } from './server-blocklist'
9 9
10@Table({ 10@Table({
@@ -52,6 +52,18 @@ export class ServerModel extends Model<Partial<AttributesOnly<ServerModel>>> {
52 }) 52 })
53 BlockedBy: ServerBlocklistModel[] 53 BlockedBy: ServerBlocklistModel[]
54 54
55 // ---------------------------------------------------------------------------
56
57 static getSQLAttributes (tableName: string, aliasPrefix = '') {
58 return buildSQLAttributes({
59 model: this,
60 tableName,
61 aliasPrefix
62 })
63 }
64
65 // ---------------------------------------------------------------------------
66
55 static load (id: number, transaction?: Transaction): Promise<MServer> { 67 static load (id: number, transaction?: Transaction): Promise<MServer> {
56 const query = { 68 const query = {
57 where: { 69 where: {
diff --git a/server/models/shared/index.ts b/server/models/shared/index.ts
index 04528929c..5a7621e4d 100644
--- a/server/models/shared/index.ts
+++ b/server/models/shared/index.ts
@@ -1,4 +1,8 @@
1export * from './abstract-run-query' 1export * from './abstract-run-query'
2export * from './model-builder' 2export * from './model-builder'
3export * from './model-cache'
3export * from './query' 4export * from './query'
5export * from './sequelize-helpers'
6export * from './sort'
7export * from './sql'
4export * from './update' 8export * from './update'
diff --git a/server/models/shared/model-builder.ts b/server/models/shared/model-builder.ts
index c015ca4f5..07f7c4038 100644
--- a/server/models/shared/model-builder.ts
+++ b/server/models/shared/model-builder.ts
@@ -1,7 +1,24 @@
1import { isPlainObject } from 'lodash' 1import { isPlainObject } from 'lodash'
2import { Model as SequelizeModel, Sequelize } from 'sequelize' 2import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
3import { logger } from '@server/helpers/logger' 3import { logger } from '@server/helpers/logger'
4 4
5/**
6 *
7 * Build Sequelize models from sequelize raw query (that must use { nest: true } options)
8 *
9 * In order to sequelize to correctly build the JSON this class will ingest,
10 * the columns selected in the raw query should be in the following form:
11 * * All tables must be Pascal Cased (for example "VideoChannel")
12 * * Root table must end with `Model` (for example "VideoCommentModel")
13 * * Joined tables must contain the origin table name + '->JoinedTable'. For example:
14 * * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
15 * * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
16 * * Selected columns must be renamed to contain the JSON path:
17 * * "videoComment"."id": "VideoCommentModel"."id"
18 * * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
19 * * All tables must contain the row id
20 */
21
5export class ModelBuilder <T extends SequelizeModel> { 22export class ModelBuilder <T extends SequelizeModel> {
6 private readonly modelRegistry = new Map<string, T>() 23 private readonly modelRegistry = new Map<string, T>()
7 24
@@ -72,18 +89,18 @@ export class ModelBuilder <T extends SequelizeModel> {
72 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName), 89 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
73 { existing: this.sequelize.modelManager.all.map(m => m.name) } 90 { existing: this.sequelize.modelManager.all.map(m => m.name) }
74 ) 91 )
75 return undefined 92 return { created: false, model: null }
76 } 93 }
77 94
78 // FIXME: typings 95 const model = Model.build(json, { raw: true, isNewRecord: false })
79 const model = new (Model as any)(json) 96
80 this.modelRegistry.set(registryKey, model) 97 this.modelRegistry.set(registryKey, model)
81 98
82 return { created: true, model } 99 return { created: true, model }
83 } 100 }
84 101
85 private findModelBuilder (modelName: string) { 102 private findModelBuilder (modelName: string) {
86 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) 103 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
87 } 104 }
88 105
89 private buildSequelizeModelName (modelName: string) { 106 private buildSequelizeModelName (modelName: string) {
diff --git a/server/models/model-cache.ts b/server/models/shared/model-cache.ts
index 3651267e7..3651267e7 100644
--- a/server/models/model-cache.ts
+++ b/server/models/shared/model-cache.ts
diff --git a/server/models/shared/query.ts b/server/models/shared/query.ts
index 036cc13c6..934acc21f 100644
--- a/server/models/shared/query.ts
+++ b/server/models/shared/query.ts
@@ -1,17 +1,82 @@
1import { BindOrReplacements, QueryTypes } from 'sequelize' 1import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database' 2import validator from 'validator'
3import { forceNumber } from '@shared/core-utils'
3 4
4function doesExist (query: string, bind?: BindOrReplacements) { 5function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) {
5 const options = { 6 const options = {
6 type: QueryTypes.SELECT as QueryTypes.SELECT, 7 type: QueryTypes.SELECT as QueryTypes.SELECT,
7 bind, 8 bind,
8 raw: true 9 raw: true
9 } 10 }
10 11
11 return sequelizeTypescript.query(query, options) 12 return sequelize.query(query, options)
12 .then(results => results.length === 1) 13 .then(results => results.length === 1)
13} 14}
14 15
16function createSimilarityAttribute (col: string, value: string) {
17 return Sequelize.fn(
18 'similarity',
19
20 searchTrigramNormalizeCol(col),
21
22 searchTrigramNormalizeValue(value)
23 )
24}
25
26function buildWhereIdOrUUID (id: number | string) {
27 return validator.isInt('' + id) ? { id } : { uuid: id }
28}
29
30function parseAggregateResult (result: any) {
31 if (!result) return 0
32
33 const total = forceNumber(result)
34 if (isNaN(total)) return 0
35
36 return total
37}
38
39function parseRowCountResult (result: any) {
40 if (result.length !== 0) return result[0].total
41
42 return 0
43}
44
45function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
46 return toEscape.map(t => {
47 return t === null
48 ? null
49 : sequelize.escape('' + t)
50 }).concat(additionalUnescaped).join(', ')
51}
52
53function searchAttribute (sourceField?: string, targetField?: string) {
54 if (!sourceField) return {}
55
56 return {
57 [targetField]: {
58 // FIXME: ts error
59 [Op.iLike as any]: `%${sourceField}%`
60 }
61 }
62}
63
15export { 64export {
16 doesExist 65 doesExist,
66 createSimilarityAttribute,
67 buildWhereIdOrUUID,
68 parseAggregateResult,
69 parseRowCountResult,
70 createSafeIn,
71 searchAttribute
72}
73
74// ---------------------------------------------------------------------------
75
76function searchTrigramNormalizeValue (value: string) {
77 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
78}
79
80function searchTrigramNormalizeCol (col: string) {
81 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
17} 82}
diff --git a/server/models/shared/sequelize-helpers.ts b/server/models/shared/sequelize-helpers.ts
new file mode 100644
index 000000000..7af8471dc
--- /dev/null
+++ b/server/models/shared/sequelize-helpers.ts
@@ -0,0 +1,39 @@
1import { Sequelize } from 'sequelize'
2
3function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
4 if (!model.createdAt || !model.updatedAt) {
5 throw new Error('Miss createdAt & updatedAt attributes to model')
6 }
7
8 const now = Date.now()
9 const createdAtTime = model.createdAt.getTime()
10 const updatedAtTime = model.updatedAt.getTime()
11
12 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
13}
14
15function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
16 if (nullable && (value === null || value === undefined)) return
17
18 if (validator(value) === false) {
19 throw new Error(`"${value}" is not a valid ${fieldName}.`)
20 }
21}
22
23function buildTrigramSearchIndex (indexName: string, attribute: string) {
24 return {
25 name: indexName,
26 // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
27 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
28 using: 'gin',
29 operator: 'gin_trgm_ops'
30 }
31}
32
33// ---------------------------------------------------------------------------
34
35export {
36 throwIfNotValid,
37 buildTrigramSearchIndex,
38 isOutdated
39}
diff --git a/server/models/shared/sort.ts b/server/models/shared/sort.ts
new file mode 100644
index 000000000..d923072f2
--- /dev/null
+++ b/server/models/shared/sort.ts
@@ -0,0 +1,146 @@
1import { literal, OrderItem, Sequelize } from 'sequelize'
2
3// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
4function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
5 const { direction, field } = buildSortDirectionAndField(value)
6
7 let finalField: string | ReturnType<typeof Sequelize.col>
8
9 if (field.toLowerCase() === 'match') { // Search
10 finalField = Sequelize.col('similarity')
11 } else {
12 finalField = field
13 }
14
15 return [ [ finalField, direction ], lastSort ]
16}
17
18function getAdminUsersSort (value: string): OrderItem[] {
19 const { direction, field } = buildSortDirectionAndField(value)
20
21 let finalField: string | ReturnType<typeof Sequelize.col>
22
23 if (field === 'videoQuotaUsed') { // Users list
24 finalField = Sequelize.col('videoQuotaUsed')
25 } else {
26 finalField = field
27 }
28
29 const nullPolicy = direction === 'ASC'
30 ? 'NULLS FIRST'
31 : 'NULLS LAST'
32
33 // FIXME: typings
34 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
35}
36
37function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
38 const { direction, field } = buildSortDirectionAndField(value)
39
40 if (field.toLowerCase() === 'name') {
41 return [ [ 'displayName', direction ], lastSort ]
42 }
43
44 return getSort(value, lastSort)
45}
46
47function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
48 const { direction, field } = buildSortDirectionAndField(value)
49
50 if (field.toLowerCase() === 'trending') { // Sort by aggregation
51 return [
52 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
53
54 [ Sequelize.col('VideoModel.views'), direction ],
55
56 lastSort
57 ]
58 } else if (field === 'publishedAt') {
59 return [
60 [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
61
62 [ Sequelize.col('VideoModel.publishedAt'), direction ],
63
64 lastSort
65 ]
66 }
67
68 let finalField: string | ReturnType<typeof Sequelize.col>
69
70 // Alias
71 if (field.toLowerCase() === 'match') { // Search
72 finalField = Sequelize.col('similarity')
73 } else {
74 finalField = field
75 }
76
77 const firstSort: OrderItem = typeof finalField === 'string'
78 ? finalField.split('.').concat([ direction ]) as OrderItem
79 : [ finalField, direction ]
80
81 return [ firstSort, lastSort ]
82}
83
84function getBlacklistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
85 const { direction, field } = buildSortDirectionAndField(value)
86
87 const videoFields = new Set([ 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid' ])
88
89 if (videoFields.has(field)) {
90 return [
91 [ literal(`"Video.${field}" ${direction}`) ],
92 lastSort
93 ] as OrderItem[]
94 }
95
96 return getSort(value, lastSort)
97}
98
99function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
100 const { direction, field } = buildSortDirectionAndField(value)
101
102 if (field === 'redundancyAllowed') {
103 return [
104 [ 'ActorFollowing.Server.redundancyAllowed', direction ],
105 lastSort
106 ]
107 }
108
109 return getSort(value, lastSort)
110}
111
112function getChannelSyncSort (value: string): OrderItem[] {
113 const { direction, field } = buildSortDirectionAndField(value)
114 if (field.toLowerCase() === 'videochannel') {
115 return [
116 [ literal('"VideoChannel.name"'), direction ]
117 ]
118 }
119 return [ [ field, direction ] ]
120}
121
122function buildSortDirectionAndField (value: string) {
123 let field: string
124 let direction: 'ASC' | 'DESC'
125
126 if (value.substring(0, 1) === '-') {
127 direction = 'DESC'
128 field = value.substring(1)
129 } else {
130 direction = 'ASC'
131 field = value
132 }
133
134 return { direction, field }
135}
136
137export {
138 buildSortDirectionAndField,
139 getPlaylistSort,
140 getSort,
141 getAdminUsersSort,
142 getVideoSort,
143 getBlacklistSort,
144 getChannelSyncSort,
145 getInstanceFollowsSort
146}
diff --git a/server/models/shared/sql.ts b/server/models/shared/sql.ts
new file mode 100644
index 000000000..5aaeb49f0
--- /dev/null
+++ b/server/models/shared/sql.ts
@@ -0,0 +1,68 @@
1import { literal, Model, ModelStatic } from 'sequelize'
2import { forceNumber } from '@shared/core-utils'
3import { AttributesOnly } from '@shared/typescript-utils'
4
5function buildLocalAccountIdsIn () {
6 return literal(
7 '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
8 )
9}
10
11function buildLocalActorIdsIn () {
12 return literal(
13 '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
14 )
15}
16
17function buildBlockedAccountSQL (blockerIds: number[]) {
18 const blockerIdsString = blockerIds.join(', ')
19
20 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
21 ' UNION ' +
22 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
23 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
24 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
25}
26
27function buildServerIdsFollowedBy (actorId: any) {
28 const actorIdNumber = forceNumber(actorId)
29
30 return '(' +
31 'SELECT "actor"."serverId" FROM "actorFollow" ' +
32 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
33 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
34 ')'
35}
36
37function buildSQLAttributes<M extends Model> (options: {
38 model: ModelStatic<M>
39 tableName: string
40
41 excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[]
42 aliasPrefix?: string
43}) {
44 const { model, tableName, aliasPrefix, excludeAttributes } = options
45
46 const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[]
47
48 return attributes
49 .filter(a => {
50 if (!excludeAttributes) return true
51 if (excludeAttributes.includes(a)) return false
52
53 return true
54 })
55 .map(a => {
56 return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"`
57 })
58}
59
60// ---------------------------------------------------------------------------
61
62export {
63 buildSQLAttributes,
64 buildBlockedAccountSQL,
65 buildServerIdsFollowedBy,
66 buildLocalAccountIdsIn,
67 buildLocalActorIdsIn
68}
diff --git a/server/models/shared/update.ts b/server/models/shared/update.ts
index d338211e3..d02c4535d 100644
--- a/server/models/shared/update.ts
+++ b/server/models/shared/update.ts
@@ -1,9 +1,15 @@
1import { QueryTypes, Transaction } from 'sequelize' 1import { QueryTypes, Sequelize, Transaction } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database'
3 2
4// Sequelize always skip the update if we only update updatedAt field 3// Sequelize always skip the update if we only update updatedAt field
5function setAsUpdated (table: string, id: number, transaction?: Transaction) { 4function setAsUpdated (options: {
6 return sequelizeTypescript.query( 5 sequelize: Sequelize
6 table: string
7 id: number
8 transaction?: Transaction
9}) {
10 const { sequelize, table, id, transaction } = options
11
12 return sequelize.query(
7 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`, 13 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`,
8 { 14 {
9 replacements: { table, id, updatedAt: new Date() }, 15 replacements: { table, id, updatedAt: new Date() },
diff --git a/server/models/user/sql/user-notitication-list-query-builder.ts b/server/models/user/sql/user-notitication-list-query-builder.ts
index 31b4932bf..7b29807a3 100644
--- a/server/models/user/sql/user-notitication-list-query-builder.ts
+++ b/server/models/user/sql/user-notitication-list-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared' 2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { getSort } from '@server/models/utils'
4import { UserNotificationModelForApi } from '@server/types/models' 3import { UserNotificationModelForApi } from '@server/types/models'
5import { ActorImageType } from '@shared/models' 4import { ActorImageType } from '@shared/models'
5import { getSort } from '../../shared'
6 6
7export interface ListNotificationsOptions { 7export interface ListNotificationsOptions {
8 userId: number 8 userId: number
@@ -180,7 +180,9 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
180 "Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type", 180 "Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type",
181 "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename", 181 "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename",
182 "Account->Actor->Server"."id" AS "Account.Actor.Server.id", 182 "Account->Actor->Server"."id" AS "Account.Actor.Server.id",
183 "Account->Actor->Server"."host" AS "Account.Actor.Server.host"` 183 "Account->Actor->Server"."host" AS "Account.Actor.Server.host",
184 "UserRegistration"."id" AS "UserRegistration.id",
185 "UserRegistration"."username" AS "UserRegistration.username"`
184 } 186 }
185 187
186 private getJoins () { 188 private getJoins () {
@@ -196,74 +198,76 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
196 ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id" 198 ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id"
197 ) ON "UserNotificationModel"."videoId" = "Video"."id" 199 ) ON "UserNotificationModel"."videoId" = "Video"."id"
198 200
199 LEFT JOIN ( 201 LEFT JOIN (
200 "videoComment" AS "VideoComment" 202 "videoComment" AS "VideoComment"
201 INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id" 203 INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
202 INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id" 204 INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
203 LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars" 205 LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
204 ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId" 206 ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
205 AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} 207 AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
206 LEFT JOIN "server" AS "VideoComment->Account->Actor->Server" 208 LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
207 ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id" 209 ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
208 INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id" 210 INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
209 ) ON "UserNotificationModel"."commentId" = "VideoComment"."id" 211 ) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
212
213 LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
214 LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
215 LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
216 LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
217 LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
218 ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
219 LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
220 ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
221 LEFT JOIN (
222 "account" AS "Abuse->FlaggedAccount"
223 INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
224 LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
225 ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
226 AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
227 LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
228 ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
229 ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
210 230
211 LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id" 231 LEFT JOIN (
212 LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId" 232 "videoBlacklist" AS "VideoBlacklist"
213 LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id" 233 INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
214 LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId" 234 ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
215 LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
216 ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
217 LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
218 ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
219 LEFT JOIN (
220 "account" AS "Abuse->FlaggedAccount"
221 INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
222 LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
223 ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
224 AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
225 LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
226 ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
227 ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
228 235
229 LEFT JOIN ( 236 LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
230 "videoBlacklist" AS "VideoBlacklist" 237 LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
231 INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
232 ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
233 238
234 LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id" 239 LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
235 LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
236 240
237 LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id" 241 LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
238 242
239 LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id" 243 LEFT JOIN (
244 "actorFollow" AS "ActorFollow"
245 INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
246 INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
247 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
248 LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
249 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
250 AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
251 LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
252 ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
253 INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
254 LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
255 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
256 LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
257 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
258 LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
259 ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
260 ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
240 261
241 LEFT JOIN ( 262 LEFT JOIN (
242 "actorFollow" AS "ActorFollow" 263 "account" AS "Account"
243 INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id" 264 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
244 INNER JOIN "account" AS "ActorFollow->ActorFollower->Account" 265 LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
245 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId" 266 ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
246 LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars" 267 AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
247 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId" 268 LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
248 AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR} 269 ) ON "UserNotificationModel"."accountId" = "Account"."id"
249 LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
250 ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
251 INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
252 LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
253 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
254 LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
255 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
256 LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
257 ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
258 ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
259 270
260 LEFT JOIN ( 271 LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"`
261 "account" AS "Account"
262 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
263 LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
264 ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
265 AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
266 LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
267 ) ON "UserNotificationModel"."accountId" = "Account"."id"`
268 } 272 }
269} 273}
diff --git a/server/models/user/user-notification-setting.ts b/server/models/user/user-notification-setting.ts
index 66e1d85b3..394494c0c 100644
--- a/server/models/user/user-notification-setting.ts
+++ b/server/models/user/user-notification-setting.ts
@@ -17,7 +17,7 @@ import { MNotificationSettingFormattable } from '@server/types/models'
17import { AttributesOnly } from '@shared/typescript-utils' 17import { AttributesOnly } from '@shared/typescript-utils'
18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model' 18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' 19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
20import { throwIfNotValid } from '../utils' 20import { throwIfNotValid } from '../shared'
21import { UserModel } from './user' 21import { UserModel } from './user'
22 22
23@Table({ 23@Table({
diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts
index d37fa5dc7..667ee7f5f 100644
--- a/server/models/user/user-notification.ts
+++ b/server/models/user/user-notification.ts
@@ -13,13 +13,14 @@ import { AccountModel } from '../account/account'
13import { ActorFollowModel } from '../actor/actor-follow' 13import { ActorFollowModel } from '../actor/actor-follow'
14import { ApplicationModel } from '../application/application' 14import { ApplicationModel } from '../application/application'
15import { PluginModel } from '../server/plugin' 15import { PluginModel } from '../server/plugin'
16import { throwIfNotValid } from '../utils' 16import { throwIfNotValid } from '../shared'
17import { VideoModel } from '../video/video' 17import { VideoModel } from '../video/video'
18import { VideoBlacklistModel } from '../video/video-blacklist' 18import { VideoBlacklistModel } from '../video/video-blacklist'
19import { VideoCommentModel } from '../video/video-comment' 19import { VideoCommentModel } from '../video/video-comment'
20import { VideoImportModel } from '../video/video-import' 20import { VideoImportModel } from '../video/video-import'
21import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder' 21import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
22import { UserModel } from './user' 22import { UserModel } from './user'
23import { UserRegistrationModel } from './user-registration'
23 24
24@Table({ 25@Table({
25 tableName: 'userNotification', 26 tableName: 'userNotification',
@@ -98,6 +99,14 @@ import { UserModel } from './user'
98 [Op.ne]: null 99 [Op.ne]: null
99 } 100 }
100 } 101 }
102 },
103 {
104 fields: [ 'userRegistrationId' ],
105 where: {
106 userRegistrationId: {
107 [Op.ne]: null
108 }
109 }
101 } 110 }
102 ] as (ModelIndexesOptions & { where?: WhereOptions })[] 111 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
103}) 112})
@@ -241,6 +250,18 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
241 }) 250 })
242 Application: ApplicationModel 251 Application: ApplicationModel
243 252
253 @ForeignKey(() => UserRegistrationModel)
254 @Column
255 userRegistrationId: number
256
257 @BelongsTo(() => UserRegistrationModel, {
258 foreignKey: {
259 allowNull: true
260 },
261 onDelete: 'cascade'
262 })
263 UserRegistration: UserRegistrationModel
264
244 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { 265 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
245 const where = { userId } 266 const where = { userId }
246 267
@@ -416,6 +437,10 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
416 ? { latestVersion: this.Application.latestPeerTubeVersion } 437 ? { latestVersion: this.Application.latestPeerTubeVersion }
417 : undefined 438 : undefined
418 439
440 const registration = this.UserRegistration
441 ? { id: this.UserRegistration.id, username: this.UserRegistration.username }
442 : undefined
443
419 return { 444 return {
420 id: this.id, 445 id: this.id,
421 type: this.type, 446 type: this.type,
@@ -429,6 +454,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
429 actorFollow, 454 actorFollow,
430 plugin, 455 plugin,
431 peertube, 456 peertube,
457 registration,
432 createdAt: this.createdAt.toISOString(), 458 createdAt: this.createdAt.toISOString(),
433 updatedAt: this.updatedAt.toISOString() 459 updatedAt: this.updatedAt.toISOString()
434 } 460 }
diff --git a/server/models/user/user-registration.ts b/server/models/user/user-registration.ts
new file mode 100644
index 000000000..adda3cc7e
--- /dev/null
+++ b/server/models/user/user-registration.ts
@@ -0,0 +1,259 @@
1import { FindOptions, Op, WhereOptions } from 'sequelize'
2import {
3 AllowNull,
4 BeforeCreate,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Is,
11 IsEmail,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import {
17 isRegistrationModerationResponseValid,
18 isRegistrationReasonValid,
19 isRegistrationStateValid
20} from '@server/helpers/custom-validators/user-registration'
21import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels'
22import { cryptPassword } from '@server/helpers/peertube-crypto'
23import { USER_REGISTRATION_STATES } from '@server/initializers/constants'
24import { MRegistration, MRegistrationFormattable } from '@server/types/models'
25import { UserRegistration, UserRegistrationState } from '@shared/models'
26import { AttributesOnly } from '@shared/typescript-utils'
27import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users'
28import { getSort, throwIfNotValid } from '../shared'
29import { UserModel } from './user'
30
31@Table({
32 tableName: 'userRegistration',
33 indexes: [
34 {
35 fields: [ 'username' ],
36 unique: true
37 },
38 {
39 fields: [ 'email' ],
40 unique: true
41 },
42 {
43 fields: [ 'channelHandle' ],
44 unique: true
45 },
46 {
47 fields: [ 'userId' ],
48 unique: true
49 }
50 ]
51})
52export class UserRegistrationModel extends Model<Partial<AttributesOnly<UserRegistrationModel>>> {
53
54 @AllowNull(false)
55 @Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state'))
56 @Column
57 state: UserRegistrationState
58
59 @AllowNull(false)
60 @Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason'))
61 @Column(DataType.TEXT)
62 registrationReason: string
63
64 @AllowNull(true)
65 @Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true))
66 @Column(DataType.TEXT)
67 moderationResponse: string
68
69 @AllowNull(true)
70 @Is('RegistrationPassword', value => throwIfNotValid(value, isUserPasswordValid, 'registration password', true))
71 @Column
72 password: string
73
74 @AllowNull(false)
75 @Column
76 username: string
77
78 @AllowNull(false)
79 @IsEmail
80 @Column(DataType.STRING(400))
81 email: string
82
83 @AllowNull(true)
84 @Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
85 @Column
86 emailVerified: boolean
87
88 @AllowNull(true)
89 @Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true))
90 @Column
91 accountDisplayName: string
92
93 @AllowNull(true)
94 @Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true))
95 @Column
96 channelHandle: string
97
98 @AllowNull(true)
99 @Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true))
100 @Column
101 channelDisplayName: string
102
103 @CreatedAt
104 createdAt: Date
105
106 @UpdatedAt
107 updatedAt: Date
108
109 @ForeignKey(() => UserModel)
110 @Column
111 userId: number
112
113 @BelongsTo(() => UserModel, {
114 foreignKey: {
115 allowNull: true
116 },
117 onDelete: 'SET NULL'
118 })
119 User: UserModel
120
121 @BeforeCreate
122 static async cryptPasswordIfNeeded (instance: UserRegistrationModel) {
123 instance.password = await cryptPassword(instance.password)
124 }
125
126 static load (id: number): Promise<MRegistration> {
127 return UserRegistrationModel.findByPk(id)
128 }
129
130 static loadByEmail (email: string): Promise<MRegistration> {
131 const query = {
132 where: { email }
133 }
134
135 return UserRegistrationModel.findOne(query)
136 }
137
138 static loadByEmailOrUsername (emailOrUsername: string): Promise<MRegistration> {
139 const query = {
140 where: {
141 [Op.or]: [
142 { email: emailOrUsername },
143 { username: emailOrUsername }
144 ]
145 }
146 }
147
148 return UserRegistrationModel.findOne(query)
149 }
150
151 static loadByEmailOrHandle (options: {
152 email: string
153 username: string
154 channelHandle?: string
155 }): Promise<MRegistration> {
156 const { email, username, channelHandle } = options
157
158 let or: WhereOptions = [
159 { email },
160 { channelHandle: username },
161 { username }
162 ]
163
164 if (channelHandle) {
165 or = or.concat([
166 { username: channelHandle },
167 { channelHandle }
168 ])
169 }
170
171 const query = {
172 where: {
173 [Op.or]: or
174 }
175 }
176
177 return UserRegistrationModel.findOne(query)
178 }
179
180 // ---------------------------------------------------------------------------
181
182 static listForApi (options: {
183 start: number
184 count: number
185 sort: string
186 search?: string
187 }) {
188 const { start, count, sort, search } = options
189
190 const where: WhereOptions = {}
191
192 if (search) {
193 Object.assign(where, {
194 [Op.or]: [
195 {
196 email: {
197 [Op.iLike]: '%' + search + '%'
198 }
199 },
200 {
201 username: {
202 [Op.iLike]: '%' + search + '%'
203 }
204 }
205 ]
206 })
207 }
208
209 const query: FindOptions = {
210 offset: start,
211 limit: count,
212 order: getSort(sort),
213 where,
214 include: [
215 {
216 model: UserModel.unscoped(),
217 required: false
218 }
219 ]
220 }
221
222 return Promise.all([
223 UserRegistrationModel.count(query),
224 UserRegistrationModel.findAll<MRegistrationFormattable>(query)
225 ]).then(([ total, data ]) => ({ total, data }))
226 }
227
228 // ---------------------------------------------------------------------------
229
230 toFormattedJSON (this: MRegistrationFormattable): UserRegistration {
231 return {
232 id: this.id,
233
234 state: {
235 id: this.state,
236 label: USER_REGISTRATION_STATES[this.state]
237 },
238
239 registrationReason: this.registrationReason,
240 moderationResponse: this.moderationResponse,
241
242 username: this.username,
243 email: this.email,
244 emailVerified: this.emailVerified,
245
246 accountDisplayName: this.accountDisplayName,
247
248 channelHandle: this.channelHandle,
249 channelDisplayName: this.channelDisplayName,
250
251 createdAt: this.createdAt,
252 updatedAt: this.updatedAt,
253
254 user: this.User
255 ? { id: this.User.id }
256 : null
257 }
258 }
259}
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 3fd808edc..bfc9b3049 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -30,6 +30,7 @@ import {
30 MUserNotifSettingChannelDefault, 30 MUserNotifSettingChannelDefault,
31 MUserWithNotificationSetting 31 MUserWithNotificationSetting
32} from '@server/types/models' 32} from '@server/types/models'
33import { forceNumber } from '@shared/core-utils'
33import { AttributesOnly } from '@shared/typescript-utils' 34import { AttributesOnly } from '@shared/typescript-utils'
34import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users' 35import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
35import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models' 36import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models'
@@ -63,14 +64,13 @@ import { ActorModel } from '../actor/actor'
63import { ActorFollowModel } from '../actor/actor-follow' 64import { ActorFollowModel } from '../actor/actor-follow'
64import { ActorImageModel } from '../actor/actor-image' 65import { ActorImageModel } from '../actor/actor-image'
65import { OAuthTokenModel } from '../oauth/oauth-token' 66import { OAuthTokenModel } from '../oauth/oauth-token'
66import { getAdminUsersSort, throwIfNotValid } from '../utils' 67import { getAdminUsersSort, throwIfNotValid } from '../shared'
67import { VideoModel } from '../video/video' 68import { VideoModel } from '../video/video'
68import { VideoChannelModel } from '../video/video-channel' 69import { VideoChannelModel } from '../video/video-channel'
69import { VideoImportModel } from '../video/video-import' 70import { VideoImportModel } from '../video/video-import'
70import { VideoLiveModel } from '../video/video-live' 71import { VideoLiveModel } from '../video/video-live'
71import { VideoPlaylistModel } from '../video/video-playlist' 72import { VideoPlaylistModel } from '../video/video-playlist'
72import { UserNotificationSettingModel } from './user-notification-setting' 73import { UserNotificationSettingModel } from './user-notification-setting'
73import { forceNumber } from '@shared/core-utils'
74 74
75enum ScopeNames { 75enum ScopeNames {
76 FOR_ME_API = 'FOR_ME_API', 76 FOR_ME_API = 'FOR_ME_API',
@@ -441,16 +441,17 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
441 }) 441 })
442 OAuthTokens: OAuthTokenModel[] 442 OAuthTokens: OAuthTokenModel[]
443 443
444 // Used if we already set an encrypted password in user model
445 skipPasswordEncryption = false
446
444 @BeforeCreate 447 @BeforeCreate
445 @BeforeUpdate 448 @BeforeUpdate
446 static cryptPasswordIfNeeded (instance: UserModel) { 449 static async cryptPasswordIfNeeded (instance: UserModel) {
447 if (instance.changed('password') && instance.password) { 450 if (instance.skipPasswordEncryption) return
448 return cryptPassword(instance.password) 451 if (!instance.changed('password')) return
449 .then(hash => { 452 if (!instance.password) return
450 instance.password = hash 453
451 return undefined 454 instance.password = await cryptPassword(instance.password)
452 })
453 }
454 } 455 }
455 456
456 @AfterUpdate 457 @AfterUpdate
diff --git a/server/models/utils.ts b/server/models/utils.ts
deleted file mode 100644
index 3476799ce..000000000
--- a/server/models/utils.ts
+++ /dev/null
@@ -1,317 +0,0 @@
1import { literal, Op, OrderItem, Sequelize } from 'sequelize'
2import validator from 'validator'
3import { forceNumber } from '@shared/core-utils'
4
5type SortType = { sortModel: string, sortValue: string }
6
7// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
8function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
9 const { direction, field } = buildDirectionAndField(value)
10
11 let finalField: string | ReturnType<typeof Sequelize.col>
12
13 if (field.toLowerCase() === 'match') { // Search
14 finalField = Sequelize.col('similarity')
15 } else {
16 finalField = field
17 }
18
19 return [ [ finalField, direction ], lastSort ]
20}
21
22function getAdminUsersSort (value: string): OrderItem[] {
23 const { direction, field } = buildDirectionAndField(value)
24
25 let finalField: string | ReturnType<typeof Sequelize.col>
26
27 if (field === 'videoQuotaUsed') { // Users list
28 finalField = Sequelize.col('videoQuotaUsed')
29 } else {
30 finalField = field
31 }
32
33 const nullPolicy = direction === 'ASC'
34 ? 'NULLS FIRST'
35 : 'NULLS LAST'
36
37 // FIXME: typings
38 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
39}
40
41function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
42 const { direction, field } = buildDirectionAndField(value)
43
44 if (field.toLowerCase() === 'name') {
45 return [ [ 'displayName', direction ], lastSort ]
46 }
47
48 return getSort(value, lastSort)
49}
50
51function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
52 const { direction, field } = buildDirectionAndField(value)
53
54 if (field === 'totalReplies') {
55 return [
56 [ Sequelize.literal('"totalReplies"'), direction ],
57 lastSort
58 ]
59 }
60
61 return getSort(value, lastSort)
62}
63
64function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
65 const { direction, field } = buildDirectionAndField(value)
66
67 if (field.toLowerCase() === 'trending') { // Sort by aggregation
68 return [
69 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
70
71 [ Sequelize.col('VideoModel.views'), direction ],
72
73 lastSort
74 ]
75 } else if (field === 'publishedAt') {
76 return [
77 [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
78
79 [ Sequelize.col('VideoModel.publishedAt'), direction ],
80
81 lastSort
82 ]
83 }
84
85 let finalField: string | ReturnType<typeof Sequelize.col>
86
87 // Alias
88 if (field.toLowerCase() === 'match') { // Search
89 finalField = Sequelize.col('similarity')
90 } else {
91 finalField = field
92 }
93
94 const firstSort: OrderItem = typeof finalField === 'string'
95 ? finalField.split('.').concat([ direction ]) as OrderItem
96 : [ finalField, direction ]
97
98 return [ firstSort, lastSort ]
99}
100
101function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
102 const [ firstSort ] = getSort(value)
103
104 if (model) return [ [ literal(`"${model}.${firstSort[0]}" ${firstSort[1]}`) ], lastSort ] as OrderItem[]
105 return [ firstSort, lastSort ]
106}
107
108function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
109 const { direction, field } = buildDirectionAndField(value)
110
111 if (field === 'redundancyAllowed') {
112 return [
113 [ 'ActorFollowing.Server.redundancyAllowed', direction ],
114 lastSort
115 ]
116 }
117
118 return getSort(value, lastSort)
119}
120
121function getChannelSyncSort (value: string): OrderItem[] {
122 const { direction, field } = buildDirectionAndField(value)
123 if (field.toLowerCase() === 'videochannel') {
124 return [
125 [ literal('"VideoChannel.name"'), direction ]
126 ]
127 }
128 return [ [ field, direction ] ]
129}
130
131function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
132 if (!model.createdAt || !model.updatedAt) {
133 throw new Error('Miss createdAt & updatedAt attributes to model')
134 }
135
136 const now = Date.now()
137 const createdAtTime = model.createdAt.getTime()
138 const updatedAtTime = model.updatedAt.getTime()
139
140 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
141}
142
143function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
144 if (nullable && (value === null || value === undefined)) return
145
146 if (validator(value) === false) {
147 throw new Error(`"${value}" is not a valid ${fieldName}.`)
148 }
149}
150
151function buildTrigramSearchIndex (indexName: string, attribute: string) {
152 return {
153 name: indexName,
154 // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
155 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
156 using: 'gin',
157 operator: 'gin_trgm_ops'
158 }
159}
160
161function createSimilarityAttribute (col: string, value: string) {
162 return Sequelize.fn(
163 'similarity',
164
165 searchTrigramNormalizeCol(col),
166
167 searchTrigramNormalizeValue(value)
168 )
169}
170
171function buildBlockedAccountSQL (blockerIds: number[]) {
172 const blockerIdsString = blockerIds.join(', ')
173
174 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
175 ' UNION ' +
176 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
177 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
178 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
179}
180
181function buildBlockedAccountSQLOptimized (columnNameJoin: string, blockerIds: number[]) {
182 const blockerIdsString = blockerIds.join(', ')
183
184 return [
185 literal(
186 `NOT EXISTS (` +
187 ` SELECT 1 FROM "accountBlocklist" ` +
188 ` WHERE "targetAccountId" = ${columnNameJoin} ` +
189 ` AND "accountId" IN (${blockerIdsString})` +
190 `)`
191 ),
192
193 literal(
194 `NOT EXISTS (` +
195 ` SELECT 1 FROM "account" ` +
196 ` INNER JOIN "actor" ON account."actorId" = actor.id ` +
197 ` INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
198 ` WHERE "account"."id" = ${columnNameJoin} ` +
199 ` AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
200 `)`
201 )
202 ]
203}
204
205function buildServerIdsFollowedBy (actorId: any) {
206 const actorIdNumber = forceNumber(actorId)
207
208 return '(' +
209 'SELECT "actor"."serverId" FROM "actorFollow" ' +
210 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
211 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
212 ')'
213}
214
215function buildWhereIdOrUUID (id: number | string) {
216 return validator.isInt('' + id) ? { id } : { uuid: id }
217}
218
219function parseAggregateResult (result: any) {
220 if (!result) return 0
221
222 const total = forceNumber(result)
223 if (isNaN(total)) return 0
224
225 return total
226}
227
228function parseRowCountResult (result: any) {
229 if (result.length !== 0) return result[0].total
230
231 return 0
232}
233
234function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
235 return stringArr.map(t => {
236 return t === null
237 ? null
238 : sequelize.escape('' + t)
239 }).join(', ')
240}
241
242function buildLocalAccountIdsIn () {
243 return literal(
244 '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
245 )
246}
247
248function buildLocalActorIdsIn () {
249 return literal(
250 '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
251 )
252}
253
254function buildDirectionAndField (value: string) {
255 let field: string
256 let direction: 'ASC' | 'DESC'
257
258 if (value.substring(0, 1) === '-') {
259 direction = 'DESC'
260 field = value.substring(1)
261 } else {
262 direction = 'ASC'
263 field = value
264 }
265
266 return { direction, field }
267}
268
269function searchAttribute (sourceField?: string, targetField?: string) {
270 if (!sourceField) return {}
271
272 return {
273 [targetField]: {
274 // FIXME: ts error
275 [Op.iLike as any]: `%${sourceField}%`
276 }
277 }
278}
279
280// ---------------------------------------------------------------------------
281
282export {
283 buildBlockedAccountSQL,
284 buildBlockedAccountSQLOptimized,
285 buildLocalActorIdsIn,
286 getPlaylistSort,
287 SortType,
288 buildLocalAccountIdsIn,
289 getSort,
290 getCommentSort,
291 getAdminUsersSort,
292 getVideoSort,
293 getBlacklistSort,
294 getChannelSyncSort,
295 createSimilarityAttribute,
296 throwIfNotValid,
297 buildServerIdsFollowedBy,
298 buildTrigramSearchIndex,
299 buildWhereIdOrUUID,
300 isOutdated,
301 parseAggregateResult,
302 getInstanceFollowsSort,
303 buildDirectionAndField,
304 createSafeIn,
305 searchAttribute,
306 parseRowCountResult
307}
308
309// ---------------------------------------------------------------------------
310
311function searchTrigramNormalizeValue (value: string) {
312 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
313}
314
315function searchTrigramNormalizeCol (col: string) {
316 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
317}
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts
index f285db477..6f05dbdc8 100644
--- a/server/models/video/formatter/video-format-utils.ts
+++ b/server/models/video/formatter/video-format-utils.ts
@@ -488,7 +488,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
488} 488}
489 489
490function getCategoryLabel (id: number) { 490function getCategoryLabel (id: number) {
491 return VIDEO_CATEGORIES[id] || 'Misc' 491 return VIDEO_CATEGORIES[id] || 'Unknown'
492} 492}
493 493
494function getLicenceLabel (id: number) { 494function getLicenceLabel (id: number) {
diff --git a/server/models/video/sql/comment/video-comment-list-query-builder.ts b/server/models/video/sql/comment/video-comment-list-query-builder.ts
new file mode 100644
index 000000000..a7eed22a1
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-list-query-builder.ts
@@ -0,0 +1,400 @@
1import { Model, Sequelize, Transaction } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { ActorImageType, VideoPrivacy } from '@shared/models'
4import { createSafeIn, getSort, parseRowCountResult } from '../../../shared'
5import { VideoCommentTableAttributes } from './video-comment-table-attributes'
6
7export interface ListVideoCommentsOptions {
8 selectType: 'api' | 'feed' | 'comment-only'
9
10 start?: number
11 count?: number
12 sort?: string
13
14 videoId?: number
15 threadId?: number
16 accountId?: number
17 videoChannelId?: number
18
19 blockerAccountIds?: number[]
20
21 isThread?: boolean
22 notDeleted?: boolean
23 isLocal?: boolean
24 onLocalVideo?: boolean
25 onPublicVideo?: boolean
26 videoAccountOwnerId?: boolean
27
28 search?: string
29 searchAccount?: string
30 searchVideo?: string
31
32 includeReplyCounters?: boolean
33
34 transaction?: Transaction
35}
36
37export class VideoCommentListQueryBuilder extends AbstractRunQuery {
38 private readonly tableAttributes = new VideoCommentTableAttributes()
39
40 private innerQuery: string
41
42 private select = ''
43 private joins = ''
44
45 private innerSelect = ''
46 private innerJoins = ''
47 private innerLateralJoins = ''
48 private innerWhere = ''
49
50 private readonly built = {
51 cte: false,
52 accountJoin: false,
53 videoJoin: false,
54 videoChannelJoin: false,
55 avatarJoin: false
56 }
57
58 constructor (
59 protected readonly sequelize: Sequelize,
60 private readonly options: ListVideoCommentsOptions
61 ) {
62 super(sequelize)
63
64 if (this.options.includeReplyCounters && !this.options.videoId) {
65 throw new Error('Cannot include reply counters without videoId')
66 }
67 }
68
69 async listComments <T extends Model> () {
70 this.buildListQuery()
71
72 const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
73 const modelBuilder = new ModelBuilder<T>(this.sequelize)
74
75 return modelBuilder.createModels(results, 'VideoComment')
76 }
77
78 async countComments () {
79 this.buildCountQuery()
80
81 const result = await this.runQuery({ transaction: this.options.transaction })
82
83 return parseRowCountResult(result)
84 }
85
86 // ---------------------------------------------------------------------------
87
88 private buildListQuery () {
89 this.buildInnerListQuery()
90 this.buildListSelect()
91
92 this.query = `${this.select} ` +
93 `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
94 `${this.joins} ` +
95 `${this.getOrder()}`
96 }
97
98 private buildInnerListQuery () {
99 this.buildWhere()
100 this.buildInnerListSelect()
101
102 this.innerQuery = `${this.innerSelect} ` +
103 `FROM "videoComment" AS "VideoCommentModel" ` +
104 `${this.innerJoins} ` +
105 `${this.innerLateralJoins} ` +
106 `${this.innerWhere} ` +
107 `${this.getOrder()} ` +
108 `${this.getInnerLimit()}`
109 }
110
111 // ---------------------------------------------------------------------------
112
113 private buildCountQuery () {
114 this.buildWhere()
115
116 this.query = `SELECT COUNT(*) AS "total" ` +
117 `FROM "videoComment" AS "VideoCommentModel" ` +
118 `${this.innerJoins} ` +
119 `${this.innerWhere}`
120 }
121
122 // ---------------------------------------------------------------------------
123
124 private buildWhere () {
125 let where: string[] = []
126
127 if (this.options.videoId) {
128 this.replacements.videoId = this.options.videoId
129
130 where.push('"VideoCommentModel"."videoId" = :videoId')
131 }
132
133 if (this.options.threadId) {
134 this.replacements.threadId = this.options.threadId
135
136 where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
137 }
138
139 if (this.options.accountId) {
140 this.replacements.accountId = this.options.accountId
141
142 where.push('"VideoCommentModel"."accountId" = :accountId')
143 }
144
145 if (this.options.videoChannelId) {
146 this.buildVideoChannelJoin()
147
148 this.replacements.videoChannelId = this.options.videoChannelId
149
150 where.push('"Account->VideoChannel"."id" = :videoChannelId')
151 }
152
153 if (this.options.blockerAccountIds) {
154 this.buildVideoChannelJoin()
155
156 where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
157 }
158
159 if (this.options.isThread === true) {
160 where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
161 }
162
163 if (this.options.notDeleted === true) {
164 where.push('"VideoCommentModel"."deletedAt" IS NULL')
165 }
166
167 if (this.options.isLocal === true) {
168 this.buildAccountJoin()
169
170 where.push('"Account->Actor"."serverId" IS NULL')
171 } else if (this.options.isLocal === false) {
172 this.buildAccountJoin()
173
174 where.push('"Account->Actor"."serverId" IS NOT NULL')
175 }
176
177 if (this.options.onLocalVideo === true) {
178 this.buildVideoJoin()
179
180 where.push('"Video"."remote" IS FALSE')
181 } else if (this.options.onLocalVideo === false) {
182 this.buildVideoJoin()
183
184 where.push('"Video"."remote" IS TRUE')
185 }
186
187 if (this.options.onPublicVideo === true) {
188 this.buildVideoJoin()
189
190 where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
191 }
192
193 if (this.options.videoAccountOwnerId) {
194 this.buildVideoChannelJoin()
195
196 this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
197
198 where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
199 }
200
201 if (this.options.search) {
202 this.buildVideoJoin()
203 this.buildAccountJoin()
204
205 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
206
207 where.push(
208 `(` +
209 `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
210 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
211 `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
212 `"Video"."name" ILIKE ${escapedLikeSearch} ` +
213 `)`
214 )
215 }
216
217 if (this.options.searchAccount) {
218 this.buildAccountJoin()
219
220 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
221
222 where.push(
223 `(` +
224 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
225 `"Account"."name" ILIKE ${escapedLikeSearch} ` +
226 `)`
227 )
228 }
229
230 if (this.options.searchVideo) {
231 this.buildVideoJoin()
232
233 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
234
235 where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
236 }
237
238 if (where.length !== 0) {
239 this.innerWhere = `WHERE ${where.join(' AND ')}`
240 }
241 }
242
243 private buildAccountJoin () {
244 if (this.built.accountJoin) return
245
246 this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
247 'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
248 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
249
250 this.built.accountJoin = true
251 }
252
253 private buildVideoJoin () {
254 if (this.built.videoJoin) return
255
256 this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
257
258 this.built.videoJoin = true
259 }
260
261 private buildVideoChannelJoin () {
262 if (this.built.videoChannelJoin) return
263
264 this.buildVideoJoin()
265
266 this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
267
268 this.built.videoChannelJoin = true
269 }
270
271 private buildAvatarsJoin () {
272 if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
273 if (this.built.avatarJoin) return
274
275 this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
276 `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
277 `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
278
279 this.built.avatarJoin = true
280 }
281
282 // ---------------------------------------------------------------------------
283
284 private buildListSelect () {
285 const toSelect = [ '"VideoCommentModel".*' ]
286
287 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
288 this.buildAvatarsJoin()
289
290 toSelect.push(this.tableAttributes.getAvatarAttributes())
291 }
292
293 this.select = this.buildSelect(toSelect)
294 }
295
296 private buildInnerListSelect () {
297 let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
298
299 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
300 this.buildAccountJoin()
301 this.buildVideoJoin()
302
303 toSelect = toSelect.concat([
304 this.tableAttributes.getVideoAttributes(),
305 this.tableAttributes.getAccountAttributes(),
306 this.tableAttributes.getActorAttributes(),
307 this.tableAttributes.getServerAttributes()
308 ])
309 }
310
311 if (this.options.includeReplyCounters === true) {
312 this.buildTotalRepliesSelect()
313 this.buildAuthorTotalRepliesSelect()
314
315 toSelect.push('"totalRepliesFromVideoAuthor"."count" AS "totalRepliesFromVideoAuthor"')
316 toSelect.push('"totalReplies"."count" AS "totalReplies"')
317 }
318
319 this.innerSelect = this.buildSelect(toSelect)
320 }
321
322 // ---------------------------------------------------------------------------
323
324 private getBlockWhere (commentTableName: string, channelTableName: string) {
325 const where: string[] = []
326
327 const blockerIdsString = createSafeIn(
328 this.sequelize,
329 this.options.blockerAccountIds,
330 [ `"${channelTableName}"."accountId"` ]
331 )
332
333 where.push(
334 `NOT EXISTS (` +
335 `SELECT 1 FROM "accountBlocklist" ` +
336 `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
337 `AND "accountId" IN (${blockerIdsString})` +
338 `)`
339 )
340
341 where.push(
342 `NOT EXISTS (` +
343 `SELECT 1 FROM "account" ` +
344 `INNER JOIN "actor" ON account."actorId" = actor.id ` +
345 `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
346 `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
347 `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
348 `)`
349 )
350
351 return where
352 }
353
354 // ---------------------------------------------------------------------------
355
356 private buildTotalRepliesSelect () {
357 const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
358
359 // Help the planner by providing videoId that should filter out many comments
360 this.replacements.videoId = this.options.videoId
361
362 this.innerLateralJoins += `LEFT JOIN LATERAL (` +
363 `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
364 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
365 `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
366 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
367 `AND "deletedAt" IS NULL ` +
368 `AND ${blockWhereString} ` +
369 `) "totalReplies" ON TRUE `
370 }
371
372 private buildAuthorTotalRepliesSelect () {
373 // Help the planner by providing videoId that should filter out many comments
374 this.replacements.videoId = this.options.videoId
375
376 this.innerLateralJoins += `LEFT JOIN LATERAL (` +
377 `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
378 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
379 `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
380 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
381 `) "totalRepliesFromVideoAuthor" ON TRUE `
382 }
383
384 private getOrder () {
385 if (!this.options.sort) return ''
386
387 const orders = getSort(this.options.sort)
388
389 return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
390 }
391
392 private getInnerLimit () {
393 if (!this.options.count) return ''
394
395 this.replacements.limit = this.options.count
396 this.replacements.offset = this.options.start || 0
397
398 return `LIMIT :limit OFFSET :offset `
399 }
400}
diff --git a/server/models/video/sql/comment/video-comment-table-attributes.ts b/server/models/video/sql/comment/video-comment-table-attributes.ts
new file mode 100644
index 000000000..87f8750c1
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-table-attributes.ts
@@ -0,0 +1,43 @@
1import { Memoize } from '@server/helpers/memoize'
2import { AccountModel } from '@server/models/account/account'
3import { ActorModel } from '@server/models/actor/actor'
4import { ActorImageModel } from '@server/models/actor/actor-image'
5import { ServerModel } from '@server/models/server/server'
6import { VideoCommentModel } from '../../video-comment'
7
8export class VideoCommentTableAttributes {
9
10 @Memoize()
11 getVideoCommentAttributes () {
12 return VideoCommentModel.getSQLAttributes('VideoCommentModel').join(', ')
13 }
14
15 @Memoize()
16 getAccountAttributes () {
17 return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
18 }
19
20 @Memoize()
21 getVideoAttributes () {
22 return [
23 `"Video"."id" AS "Video.id"`,
24 `"Video"."uuid" AS "Video.uuid"`,
25 `"Video"."name" AS "Video.name"`
26 ].join(', ')
27 }
28
29 @Memoize()
30 getActorAttributes () {
31 return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
32 }
33
34 @Memoize()
35 getServerAttributes () {
36 return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
37 }
38
39 @Memoize()
40 getAvatarAttributes () {
41 return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
42 }
43}
diff --git a/server/models/video/sql/video/shared/abstract-video-query-builder.ts b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
index f0ce69501..cbd57ad8c 100644
--- a/server/models/video/sql/video/shared/abstract-video-query-builder.ts
+++ b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
@@ -1,9 +1,9 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import validator from 'validator' 2import validator from 'validator'
3import { createSafeIn } from '@server/models/utils'
4import { MUserAccountId } from '@server/types/models' 3import { MUserAccountId } from '@server/types/models'
5import { ActorImageType } from '@shared/models' 4import { ActorImageType } from '@shared/models'
6import { AbstractRunQuery } from '../../../../shared/abstract-run-query' 5import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
6import { createSafeIn } from '../../../../shared'
7import { VideoTableAttributes } from './video-table-attributes' 7import { VideoTableAttributes } from './video-table-attributes'
8 8
9/** 9/**
diff --git a/server/models/video/sql/video/videos-id-list-query-builder.ts b/server/models/video/sql/video/videos-id-list-query-builder.ts
index 7c864bf27..62f1855c7 100644
--- a/server/models/video/sql/video/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-id-list-query-builder.ts
@@ -2,11 +2,12 @@ import { Sequelize, Transaction } from 'sequelize'
2import validator from 'validator' 2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc' 3import { exists } from '@server/helpers/custom-validators/misc'
4import { WEBSERVER } from '@server/initializers/constants' 4import { WEBSERVER } from '@server/initializers/constants'
5import { buildDirectionAndField, createSafeIn, parseRowCountResult } from '@server/models/utils' 5import { buildSortDirectionAndField } from '@server/models/shared'
6import { MUserAccountId, MUserId } from '@server/types/models' 6import { MUserAccountId, MUserId } from '@server/types/models'
7import { forceNumber } from '@shared/core-utils'
7import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' 8import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
9import { createSafeIn, parseRowCountResult } from '../../../shared'
8import { AbstractRunQuery } from '../../../shared/abstract-run-query' 10import { AbstractRunQuery } from '../../../shared/abstract-run-query'
9import { forceNumber } from '@shared/core-utils'
10 11
11/** 12/**
12 * 13 *
@@ -665,7 +666,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
665 } 666 }
666 667
667 private buildOrder (value: string) { 668 private buildOrder (value: string) {
668 const { direction, field } = buildDirectionAndField(value) 669 const { direction, field } = buildSortDirectionAndField(value)
669 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field) 670 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
670 671
671 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()' 672 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index 653b9694b..cebde3755 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -4,7 +4,7 @@ import { MTag } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils' 4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoPrivacy, VideoState } from '../../../shared/models/videos' 5import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
6import { isVideoTagValid } from '../../helpers/custom-validators/videos' 6import { isVideoTagValid } from '../../helpers/custom-validators/videos'
7import { throwIfNotValid } from '../utils' 7import { throwIfNotValid } from '../shared'
8import { VideoModel } from './video' 8import { VideoModel } from './video'
9import { VideoTagModel } from './video-tag' 9import { VideoTagModel } from './video-tag'
10 10
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 1cd8224c0..9247d0e2b 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -5,7 +5,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos' 5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' 6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
7import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
8import { getBlacklistSort, searchAttribute, SortType, throwIfNotValid } from '../utils' 8import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared'
9import { ThumbnailModel } from './thumbnail' 9import { ThumbnailModel } from './thumbnail'
10import { VideoModel } from './video' 10import { VideoModel } from './video'
11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' 11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
@@ -57,7 +57,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
57 static listForApi (parameters: { 57 static listForApi (parameters: {
58 start: number 58 start: number
59 count: number 59 count: number
60 sort: SortType 60 sort: string
61 search?: string 61 search?: string
62 type?: VideoBlacklistType 62 type?: VideoBlacklistType
63 }) { 63 }) {
@@ -67,7 +67,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
67 return { 67 return {
68 offset: start, 68 offset: start,
69 limit: count, 69 limit: count,
70 order: getBlacklistSort(sort.sortModel, sort.sortValue) 70 order: getBlacklistSort(sort)
71 } 71 }
72 } 72 }
73 73
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index 5fbcd6e3b..2eaa77407 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -23,7 +23,7 @@ import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/vid
23import { logger } from '../../helpers/logger' 23import { logger } from '../../helpers/logger'
24import { CONFIG } from '../../initializers/config' 24import { CONFIG } from '../../initializers/config'
25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' 25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
26import { buildWhereIdOrUUID, throwIfNotValid } from '../utils' 26import { buildWhereIdOrUUID, throwIfNotValid } from '../shared'
27import { VideoModel } from './video' 27import { VideoModel } from './video'
28 28
29export enum ScopeNames { 29export enum ScopeNames {
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
index 1a1b8c88d..2db4b523a 100644
--- a/server/models/video/video-change-ownership.ts
+++ b/server/models/video/video-change-ownership.ts
@@ -3,7 +3,7 @@ import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@se
3import { AttributesOnly } from '@shared/typescript-utils' 3import { AttributesOnly } from '@shared/typescript-utils'
4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' 4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
5import { AccountModel } from '../account/account' 5import { AccountModel } from '../account/account'
6import { getSort } from '../utils' 6import { getSort } from '../shared'
7import { ScopeNames as VideoScopeNames, VideoModel } from './video' 7import { ScopeNames as VideoScopeNames, VideoModel } from './video'
8 8
9enum ScopeNames { 9enum ScopeNames {
diff --git a/server/models/video/video-channel-sync.ts b/server/models/video/video-channel-sync.ts
index 6e49cde10..a4cbf51f5 100644
--- a/server/models/video/video-channel-sync.ts
+++ b/server/models/video/video-channel-sync.ts
@@ -21,7 +21,7 @@ import { VideoChannelSync, VideoChannelSyncState } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 21import { AttributesOnly } from '@shared/typescript-utils'
22import { AccountModel } from '../account/account' 22import { AccountModel } from '../account/account'
23import { UserModel } from '../user/user' 23import { UserModel } from '../user/user'
24import { getChannelSyncSort, throwIfNotValid } from '../utils' 24import { getChannelSyncSort, throwIfNotValid } from '../shared'
25import { VideoChannelModel } from './video-channel' 25import { VideoChannelModel } from './video-channel'
26 26
27@DefaultScope(() => ({ 27@DefaultScope(() => ({
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 132c8f021..b71f5a197 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -43,8 +43,14 @@ import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
43import { ActorFollowModel } from '../actor/actor-follow' 43import { ActorFollowModel } from '../actor/actor-follow'
44import { ActorImageModel } from '../actor/actor-image' 44import { ActorImageModel } from '../actor/actor-image'
45import { ServerModel } from '../server/server' 45import { ServerModel } from '../server/server'
46import { setAsUpdated } from '../shared' 46import {
47import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 47 buildServerIdsFollowedBy,
48 buildTrigramSearchIndex,
49 createSimilarityAttribute,
50 getSort,
51 setAsUpdated,
52 throwIfNotValid
53} from '../shared'
48import { VideoModel } from './video' 54import { VideoModel } from './video'
49import { VideoPlaylistModel } from './video-playlist' 55import { VideoPlaylistModel } from './video-playlist'
50 56
@@ -831,6 +837,6 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
831 } 837 }
832 838
833 setAsUpdated (transaction?: Transaction) { 839 setAsUpdated (transaction?: Transaction) {
834 return setAsUpdated('videoChannel', this.id, transaction) 840 return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
835 } 841 }
836} 842}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index af9614d30..ff5142809 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,4 +1,4 @@
1import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 1import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
2import { 2import {
3 AllowNull, 3 AllowNull,
4 BelongsTo, 4 BelongsTo,
@@ -13,11 +13,9 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { exists } from '@server/helpers/custom-validators/misc'
17import { getServerActor } from '@server/models/application/application' 16import { getServerActor } from '@server/models/application/application'
18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' 17import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
19import { uniqify } from '@shared/core-utils' 18import { pick, uniqify } from '@shared/core-utils'
20import { VideoPrivacy } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 19import { AttributesOnly } from '@shared/typescript-utils'
22import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' 20import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
23import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 21import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
@@ -41,61 +39,19 @@ import {
41} from '../../types/models/video' 39} from '../../types/models/video'
42import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' 40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
43import { AccountModel } from '../account/account' 41import { AccountModel } from '../account/account'
44import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor' 42import { ActorModel } from '../actor/actor'
45import { 43import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared'
46 buildBlockedAccountSQL, 44import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
47 buildBlockedAccountSQLOptimized,
48 buildLocalAccountIdsIn,
49 getCommentSort,
50 searchAttribute,
51 throwIfNotValid
52} from '../utils'
53import { VideoModel } from './video' 45import { VideoModel } from './video'
54import { VideoChannelModel } from './video-channel' 46import { VideoChannelModel } from './video-channel'
55 47
56export enum ScopeNames { 48export enum ScopeNames {
57 WITH_ACCOUNT = 'WITH_ACCOUNT', 49 WITH_ACCOUNT = 'WITH_ACCOUNT',
58 WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
59 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO', 50 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
60 WITH_VIDEO = 'WITH_VIDEO', 51 WITH_VIDEO = 'WITH_VIDEO'
61 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
62} 52}
63 53
64@Scopes(() => ({ 54@Scopes(() => ({
65 [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
66 return {
67 attributes: {
68 include: [
69 [
70 Sequelize.literal(
71 '(' +
72 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
73 'SELECT COUNT("replies"."id") ' +
74 'FROM "videoComment" AS "replies" ' +
75 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
76 'AND "deletedAt" IS NULL ' +
77 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
78 ')'
79 ),
80 'totalReplies'
81 ],
82 [
83 Sequelize.literal(
84 '(' +
85 'SELECT COUNT("replies"."id") ' +
86 'FROM "videoComment" AS "replies" ' +
87 'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
88 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
89 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
90 'AND "replies"."accountId" = "videoChannel"."accountId"' +
91 ')'
92 ),
93 'totalRepliesFromVideoAuthor'
94 ]
95 ]
96 }
97 } as FindOptions
98 },
99 [ScopeNames.WITH_ACCOUNT]: { 55 [ScopeNames.WITH_ACCOUNT]: {
100 include: [ 56 include: [
101 { 57 {
@@ -103,22 +59,6 @@ export enum ScopeNames {
103 } 59 }
104 ] 60 ]
105 }, 61 },
106 [ScopeNames.WITH_ACCOUNT_FOR_API]: {
107 include: [
108 {
109 model: AccountModel.unscoped(),
110 include: [
111 {
112 attributes: {
113 exclude: unusedActorAttributesForAPI
114 },
115 model: ActorModel, // Default scope includes avatar and server
116 required: true
117 }
118 ]
119 }
120 ]
121 },
122 [ScopeNames.WITH_IN_REPLY_TO]: { 62 [ScopeNames.WITH_IN_REPLY_TO]: {
123 include: [ 63 include: [
124 { 64 {
@@ -252,6 +192,18 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
252 }) 192 })
253 CommentAbuses: VideoCommentAbuseModel[] 193 CommentAbuses: VideoCommentAbuseModel[]
254 194
195 // ---------------------------------------------------------------------------
196
197 static getSQLAttributes (tableName: string, aliasPrefix = '') {
198 return buildSQLAttributes({
199 model: this,
200 tableName,
201 aliasPrefix
202 })
203 }
204
205 // ---------------------------------------------------------------------------
206
255 static loadById (id: number, t?: Transaction): Promise<MComment> { 207 static loadById (id: number, t?: Transaction): Promise<MComment> {
256 const query: FindOptions = { 208 const query: FindOptions = {
257 where: { 209 where: {
@@ -319,93 +271,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
319 searchAccount?: string 271 searchAccount?: string
320 searchVideo?: string 272 searchVideo?: string
321 }) { 273 }) {
322 const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters 274 const queryOptions: ListVideoCommentsOptions = {
275 ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
323 276
324 const where: WhereOptions = { 277 selectType: 'api',
325 deletedAt: null 278 notDeleted: true
326 }
327
328 const whereAccount: WhereOptions = {}
329 const whereActor: WhereOptions = {}
330 const whereVideo: WhereOptions = {}
331
332 if (isLocal === true) {
333 Object.assign(whereActor, {
334 serverId: null
335 })
336 } else if (isLocal === false) {
337 Object.assign(whereActor, {
338 serverId: {
339 [Op.ne]: null
340 }
341 })
342 }
343
344 if (search) {
345 Object.assign(where, {
346 [Op.or]: [
347 searchAttribute(search, 'text'),
348 searchAttribute(search, '$Account.Actor.preferredUsername$'),
349 searchAttribute(search, '$Account.name$'),
350 searchAttribute(search, '$Video.name$')
351 ]
352 })
353 }
354
355 if (searchAccount) {
356 Object.assign(whereActor, {
357 [Op.or]: [
358 searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
359 searchAttribute(searchAccount, '$Account.name$')
360 ]
361 })
362 }
363
364 if (searchVideo) {
365 Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
366 }
367
368 if (exists(onLocalVideo)) {
369 Object.assign(whereVideo, { remote: !onLocalVideo })
370 }
371
372 const getQuery = (forCount: boolean) => {
373 return {
374 offset: start,
375 limit: count,
376 order: getCommentSort(sort),
377 where,
378 include: [
379 {
380 model: AccountModel.unscoped(),
381 required: true,
382 where: whereAccount,
383 include: [
384 {
385 attributes: {
386 exclude: unusedActorAttributesForAPI
387 },
388 model: forCount === true
389 ? ActorModel.unscoped() // Default scope includes avatar and server
390 : ActorModel,
391 required: true,
392 where: whereActor
393 }
394 ]
395 },
396 {
397 model: VideoModel.unscoped(),
398 required: true,
399 where: whereVideo
400 }
401 ]
402 }
403 } 279 }
404 280
405 return Promise.all([ 281 return Promise.all([
406 VideoCommentModel.count(getQuery(true)), 282 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
407 VideoCommentModel.findAll(getQuery(false)) 283 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
408 ]).then(([ total, data ]) => ({ total, data })) 284 ]).then(([ rows, count ]) => {
285 return { total: count, data: rows }
286 })
409 } 287 }
410 288
411 static async listThreadsForApi (parameters: { 289 static async listThreadsForApi (parameters: {
@@ -416,67 +294,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
416 sort: string 294 sort: string
417 user?: MUserAccountId 295 user?: MUserAccountId
418 }) { 296 }) {
419 const { videoId, isVideoOwned, start, count, sort, user } = parameters 297 const { videoId, user } = parameters
420 298
421 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 299 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
422 300
423 const accountBlockedWhere = { 301 const commonOptions: ListVideoCommentsOptions = {
424 accountId: { 302 selectType: 'api',
425 [Op.notIn]: Sequelize.literal( 303 videoId,
426 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')' 304 blockerAccountIds
427 )
428 }
429 } 305 }
430 306
431 const queryList = { 307 const listOptions: ListVideoCommentsOptions = {
432 offset: start, 308 ...commonOptions,
433 limit: count, 309 ...pick(parameters, [ 'sort', 'start', 'count' ]),
434 order: getCommentSort(sort), 310
435 where: { 311 isThread: true,
436 [Op.and]: [ 312 includeReplyCounters: true
437 {
438 videoId
439 },
440 {
441 inReplyToCommentId: null
442 },
443 {
444 [Op.or]: [
445 accountBlockedWhere,
446 {
447 accountId: null
448 }
449 ]
450 }
451 ]
452 }
453 } 313 }
454 314
455 const findScopesList: (string | ScopeOptions)[] = [ 315 const countOptions: ListVideoCommentsOptions = {
456 ScopeNames.WITH_ACCOUNT_FOR_API, 316 ...commonOptions,
457 {
458 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
459 }
460 ]
461 317
462 const countScopesList: ScopeOptions[] = [ 318 isThread: true
463 { 319 }
464 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
465 }
466 ]
467 320
468 const notDeletedQueryCount = { 321 const notDeletedCountOptions: ListVideoCommentsOptions = {
469 where: { 322 ...commonOptions,
470 videoId, 323
471 deletedAt: null, 324 notDeleted: true
472 ...accountBlockedWhere
473 }
474 } 325 }
475 326
476 return Promise.all([ 327 return Promise.all([
477 VideoCommentModel.scope(findScopesList).findAll(queryList), 328 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
478 VideoCommentModel.scope(countScopesList).count(queryList), 329 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
479 VideoCommentModel.count(notDeletedQueryCount) 330 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
480 ]).then(([ rows, count, totalNotDeletedComments ]) => { 331 ]).then(([ rows, count, totalNotDeletedComments ]) => {
481 return { total: count, data: rows, totalNotDeletedComments } 332 return { total: count, data: rows, totalNotDeletedComments }
482 }) 333 })
@@ -484,54 +335,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
484 335
485 static async listThreadCommentsForApi (parameters: { 336 static async listThreadCommentsForApi (parameters: {
486 videoId: number 337 videoId: number
487 isVideoOwned: boolean
488 threadId: number 338 threadId: number
489 user?: MUserAccountId 339 user?: MUserAccountId
490 }) { 340 }) {
491 const { videoId, threadId, user, isVideoOwned } = parameters 341 const { user } = parameters
492 342
493 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 343 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
494 344
495 const query = { 345 const queryOptions: ListVideoCommentsOptions = {
496 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, 346 ...pick(parameters, [ 'videoId', 'threadId' ]),
497 where: {
498 videoId,
499 [Op.and]: [
500 {
501 [Op.or]: [
502 { id: threadId },
503 { originCommentId: threadId }
504 ]
505 },
506 {
507 [Op.or]: [
508 {
509 accountId: {
510 [Op.notIn]: Sequelize.literal(
511 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
512 )
513 }
514 },
515 {
516 accountId: null
517 }
518 ]
519 }
520 ]
521 }
522 }
523 347
524 const scopes: any[] = [ 348 selectType: 'api',
525 ScopeNames.WITH_ACCOUNT_FOR_API, 349 sort: 'createdAt',
526 { 350
527 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] 351 blockerAccountIds,
528 } 352 includeReplyCounters: true
529 ] 353 }
530 354
531 return Promise.all([ 355 return Promise.all([
532 VideoCommentModel.count(query), 356 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
533 VideoCommentModel.scope(scopes).findAll(query) 357 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
534 ]).then(([ total, data ]) => ({ total, data })) 358 ]).then(([ rows, count ]) => {
359 return { total: count, data: rows }
360 })
535 } 361 }
536 362
537 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> { 363 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@@ -559,31 +385,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
559 .findAll(query) 385 .findAll(query)
560 } 386 }
561 387
562 static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) { 388 static async listAndCountByVideoForAP (parameters: {
563 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ 389 video: MVideoImmutable
390 start: number
391 count: number
392 }) {
393 const { video } = parameters
394
395 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
396
397 const queryOptions: ListVideoCommentsOptions = {
398 ...pick(parameters, [ 'start', 'count' ]),
399
400 selectType: 'comment-only',
564 videoId: video.id, 401 videoId: video.id,
565 isVideoOwned: video.isOwned() 402 sort: 'createdAt',
566 })
567 403
568 const query = { 404 blockerAccountIds
569 order: [ [ 'createdAt', 'ASC' ] ] as Order,
570 offset: start,
571 limit: count,
572 where: {
573 videoId: video.id,
574 accountId: {
575 [Op.notIn]: Sequelize.literal(
576 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
577 )
578 }
579 },
580 transaction: t
581 } 405 }
582 406
583 return Promise.all([ 407 return Promise.all([
584 VideoCommentModel.count(query), 408 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
585 VideoCommentModel.findAll<MComment>(query) 409 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
586 ]).then(([ total, data ]) => ({ total, data })) 410 ]).then(([ rows, count ]) => {
411 return { total: count, data: rows }
412 })
587 } 413 }
588 414
589 static async listForFeed (parameters: { 415 static async listForFeed (parameters: {
@@ -592,97 +418,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
592 videoId?: number 418 videoId?: number
593 accountId?: number 419 accountId?: number
594 videoChannelId?: number 420 videoChannelId?: number
595 }): Promise<MCommentOwnerVideoFeed[]> { 421 }) {
596 const serverActor = await getServerActor() 422 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
597 const { start, count, videoId, accountId, videoChannelId } = parameters
598
599 const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
600 '"VideoCommentModel"."accountId"',
601 [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
602 )
603 423
604 if (accountId) { 424 const queryOptions: ListVideoCommentsOptions = {
605 whereAnd.push({ 425 ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
606 accountId
607 })
608 }
609 426
610 const accountWhere = { 427 selectType: 'feed',
611 [Op.and]: whereAnd
612 }
613 428
614 const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined 429 sort: '-createdAt',
430 onPublicVideo: true,
431 notDeleted: true,
615 432
616 const query = { 433 blockerAccountIds
617 order: [ [ 'createdAt', 'DESC' ] ] as Order,
618 offset: start,
619 limit: count,
620 where: {
621 deletedAt: null,
622 accountId: accountWhere
623 },
624 include: [
625 {
626 attributes: [ 'name', 'uuid' ],
627 model: VideoModel.unscoped(),
628 required: true,
629 where: {
630 privacy: VideoPrivacy.PUBLIC
631 },
632 include: [
633 {
634 attributes: [ 'accountId' ],
635 model: VideoChannelModel.unscoped(),
636 required: true,
637 where: videoChannelWhere
638 }
639 ]
640 }
641 ]
642 } 434 }
643 435
644 if (videoId) query.where['videoId'] = videoId 436 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
645
646 return VideoCommentModel
647 .scope([ ScopeNames.WITH_ACCOUNT ])
648 .findAll(query)
649 } 437 }
650 438
651 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) { 439 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
652 const accountWhere = filter.onVideosOfAccount 440 const queryOptions: ListVideoCommentsOptions = {
653 ? { id: filter.onVideosOfAccount.id } 441 selectType: 'comment-only',
654 : {}
655 442
656 const query = { 443 accountId: ofAccount.id,
657 limit: 1000, 444 videoAccountOwnerId: filter.onVideosOfAccount?.id,
658 where: { 445
659 deletedAt: null, 446 notDeleted: true,
660 accountId: ofAccount.id 447 count: 5000
661 },
662 include: [
663 {
664 model: VideoModel,
665 required: true,
666 include: [
667 {
668 model: VideoChannelModel,
669 required: true,
670 include: [
671 {
672 model: AccountModel,
673 required: true,
674 where: accountWhere
675 }
676 ]
677 }
678 ]
679 }
680 ]
681 } 448 }
682 449
683 return VideoCommentModel 450 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
684 .scope([ ScopeNames.WITH_ACCOUNT ])
685 .findAll(query)
686 } 451 }
687 452
688 static async getStats () { 453 static async getStats () {
@@ -750,9 +515,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
750 } 515 }
751 516
752 isOwned () { 517 isOwned () {
753 if (!this.Account) { 518 if (!this.Account) return false
754 return false
755 }
756 519
757 return this.Account.isOwned() 520 return this.Account.isOwned()
758 } 521 }
@@ -906,22 +669,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
906 } 669 }
907 670
908 private static async buildBlockerAccountIds (options: { 671 private static async buildBlockerAccountIds (options: {
909 videoId: number 672 user: MUserAccountId
910 isVideoOwned: boolean 673 }): Promise<number[]> {
911 user?: MUserAccountId 674 const { user } = options
912 }) {
913 const { videoId, user, isVideoOwned } = options
914 675
915 const serverActor = await getServerActor() 676 const serverActor = await getServerActor()
916 const blockerAccountIds = [ serverActor.Account.id ] 677 const blockerAccountIds = [ serverActor.Account.id ]
917 678
918 if (user) blockerAccountIds.push(user.Account.id) 679 if (user) blockerAccountIds.push(user.Account.id)
919 680
920 if (isVideoOwned) {
921 const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
922 if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
923 }
924
925 return blockerAccountIds 681 return blockerAccountIds
926 } 682 }
927} 683}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 9c4e6d078..07bc13de1 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -21,6 +21,7 @@ import {
21import validator from 'validator' 21import validator from 'validator'
22import { logger } from '@server/helpers/logger' 22import { logger } from '@server/helpers/logger'
23import { extractVideo } from '@server/helpers/video' 23import { extractVideo } from '@server/helpers/video'
24import { CONFIG } from '@server/initializers/config'
24import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url' 25import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
25import { 26import {
26 getHLSPrivateFileUrl, 27 getHLSPrivateFileUrl,
@@ -50,11 +51,9 @@ import {
50} from '../../initializers/constants' 51} from '../../initializers/constants'
51import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file' 52import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
52import { VideoRedundancyModel } from '../redundancy/video-redundancy' 53import { VideoRedundancyModel } from '../redundancy/video-redundancy'
53import { doesExist } from '../shared' 54import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
54import { parseAggregateResult, throwIfNotValid } from '../utils'
55import { VideoModel } from './video' 55import { VideoModel } from './video'
56import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 56import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
57import { CONFIG } from '@server/initializers/config'
58 57
59export enum ScopeNames { 58export enum ScopeNames {
60 WITH_VIDEO = 'WITH_VIDEO', 59 WITH_VIDEO = 'WITH_VIDEO',
@@ -266,7 +265,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
266 static doesInfohashExist (infoHash: string) { 265 static doesInfohashExist (infoHash: string) {
267 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 266 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
268 267
269 return doesExist(query, { infoHash }) 268 return doesExist(this.sequelize, query, { infoHash })
270 } 269 }
271 270
272 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { 271 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
@@ -282,14 +281,14 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
282 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' + 281 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
283 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1' 282 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1'
284 283
285 return doesExist(query, { filename }) 284 return doesExist(this.sequelize, query, { filename })
286 } 285 }
287 286
288 static async doesOwnedWebTorrentVideoFileExist (filename: string) { 287 static async doesOwnedWebTorrentVideoFileExist (filename: string) {
289 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' + 288 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
290 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1` 289 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
291 290
292 return doesExist(query, { filename }) 291 return doesExist(this.sequelize, query, { filename })
293 } 292 }
294 293
295 static loadByFilename (filename: string) { 294 static loadByFilename (filename: string) {
@@ -439,7 +438,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
439 if (!element) return videoFile.save({ transaction }) 438 if (!element) return videoFile.save({ transaction })
440 439
441 for (const k of Object.keys(videoFile.toJSON())) { 440 for (const k of Object.keys(videoFile.toJSON())) {
442 element[k] = videoFile[k] 441 element.set(k, videoFile[k])
443 } 442 }
444 443
445 return element.save({ transaction }) 444 return element.save({ transaction })
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index da6b92c7a..c040e0fda 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -22,7 +22,7 @@ import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../help
22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' 22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants' 23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
24import { UserModel } from '../user/user' 24import { UserModel } from '../user/user'
25import { getSort, searchAttribute, throwIfNotValid } from '../utils' 25import { getSort, searchAttribute, throwIfNotValid } from '../shared'
26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' 26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
27import { VideoChannelSyncModel } from './video-channel-sync' 27import { VideoChannelSyncModel } from './video-channel-sync'
28 28
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index 7181b5599..b832f9768 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -31,7 +31,7 @@ import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/
31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 32import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
33import { AccountModel } from '../account/account' 33import { AccountModel } from '../account/account'
34import { getSort, throwIfNotValid } from '../utils' 34import { getSort, throwIfNotValid } from '../shared'
35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' 35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
36import { VideoPlaylistModel } from './video-playlist' 36import { VideoPlaylistModel } from './video-playlist'
37 37
@@ -309,7 +309,23 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
309 return VideoPlaylistElementModel.increment({ position: by }, query) 309 return VideoPlaylistElementModel.increment({ position: by }, query)
310 } 310 }
311 311
312 getType (this: MVideoPlaylistElementFormattable, displayNSFW?: boolean, accountId?: number) { 312 toFormattedJSON (
313 this: MVideoPlaylistElementFormattable,
314 options: { accountId?: number } = {}
315 ): VideoPlaylistElement {
316 return {
317 id: this.id,
318 position: this.position,
319 startTimestamp: this.startTimestamp,
320 stopTimestamp: this.stopTimestamp,
321
322 type: this.getType(options.accountId),
323
324 video: this.getVideoElement(options.accountId)
325 }
326 }
327
328 getType (this: MVideoPlaylistElementFormattable, accountId?: number) {
313 const video = this.Video 329 const video = this.Video
314 330
315 if (!video) return VideoPlaylistElementType.DELETED 331 if (!video) return VideoPlaylistElementType.DELETED
@@ -323,34 +339,17 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
323 if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE 339 if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE
324 340
325 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE 341 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
326 if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE
327 342
328 return VideoPlaylistElementType.REGULAR 343 return VideoPlaylistElementType.REGULAR
329 } 344 }
330 345
331 getVideoElement (this: MVideoPlaylistElementFormattable, displayNSFW?: boolean, accountId?: number) { 346 getVideoElement (this: MVideoPlaylistElementFormattable, accountId?: number) {
332 if (!this.Video) return null 347 if (!this.Video) return null
333 if (this.getType(displayNSFW, accountId) !== VideoPlaylistElementType.REGULAR) return null 348 if (this.getType(accountId) !== VideoPlaylistElementType.REGULAR) return null
334 349
335 return this.Video.toFormattedJSON() 350 return this.Video.toFormattedJSON()
336 } 351 }
337 352
338 toFormattedJSON (
339 this: MVideoPlaylistElementFormattable,
340 options: { displayNSFW?: boolean, accountId?: number } = {}
341 ): VideoPlaylistElement {
342 return {
343 id: this.id,
344 position: this.position,
345 startTimestamp: this.startTimestamp,
346 stopTimestamp: this.stopTimestamp,
347
348 type: this.getType(options.displayNSFW, options.accountId),
349
350 video: this.getVideoElement(options.displayNSFW, options.accountId)
351 }
352 }
353
354 toActivityPubObject (this: MVideoPlaylistElementAP): PlaylistElementObject { 353 toActivityPubObject (this: MVideoPlaylistElementAP): PlaylistElementObject {
355 const base: PlaylistElementObject = { 354 const base: PlaylistElementObject = {
356 id: this.url, 355 id: this.url,
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 8bbe54c49..faf4bea78 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -21,12 +21,8 @@ import { activityPubCollectionPagination } from '@server/lib/activitypub/collect
21import { MAccountId, MChannelId } from '@server/types/models' 21import { MAccountId, MChannelId } from '@server/types/models'
22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils' 22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
23import { buildUUID, uuidToShort } from '@shared/extra-utils' 23import { buildUUID, uuidToShort } from '@shared/extra-utils'
24import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
24import { AttributesOnly } from '@shared/typescript-utils' 25import { AttributesOnly } from '@shared/typescript-utils'
25import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
26import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
27import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
28import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
29import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
30import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 26import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
31import { 27import {
32 isVideoPlaylistDescriptionValid, 28 isVideoPlaylistDescriptionValid,
@@ -53,7 +49,6 @@ import {
53} from '../../types/models/video/video-playlist' 49} from '../../types/models/video/video-playlist'
54import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' 50import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
55import { ActorModel } from '../actor/actor' 51import { ActorModel } from '../actor/actor'
56import { setAsUpdated } from '../shared'
57import { 52import {
58 buildServerIdsFollowedBy, 53 buildServerIdsFollowedBy,
59 buildTrigramSearchIndex, 54 buildTrigramSearchIndex,
@@ -61,8 +56,9 @@ import {
61 createSimilarityAttribute, 56 createSimilarityAttribute,
62 getPlaylistSort, 57 getPlaylistSort,
63 isOutdated, 58 isOutdated,
59 setAsUpdated,
64 throwIfNotValid 60 throwIfNotValid
65} from '../utils' 61} from '../shared'
66import { ThumbnailModel } from './thumbnail' 62import { ThumbnailModel } from './thumbnail'
67import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 63import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
68import { VideoPlaylistElementModel } from './video-playlist-element' 64import { VideoPlaylistElementModel } from './video-playlist-element'
@@ -641,7 +637,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
641 } 637 }
642 638
643 setAsRefreshed () { 639 setAsRefreshed () {
644 return setAsUpdated('videoPlaylist', this.id) 640 return setAsUpdated({ sequelize: this.sequelize, table: 'videoPlaylist', id: this.id })
645 } 641 }
646 642
647 setVideosLength (videosLength: number) { 643 setVideosLength (videosLength: number) {
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index f2190037e..b4de2b20f 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -7,7 +7,7 @@ import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models' 7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models'
8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video' 8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
9import { ActorModel } from '../actor/actor' 9import { ActorModel } from '../actor/actor'
10import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' 10import { buildLocalActorIdsIn, throwIfNotValid } from '../shared'
11import { VideoModel } from './video' 11import { VideoModel } from './video'
12 12
13enum ScopeNames { 13enum ScopeNames {
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index 0386edf28..a85c79c9f 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -37,8 +37,7 @@ import {
37 WEBSERVER 37 WEBSERVER
38} from '../../initializers/constants' 38} from '../../initializers/constants'
39import { VideoRedundancyModel } from '../redundancy/video-redundancy' 39import { VideoRedundancyModel } from '../redundancy/video-redundancy'
40import { doesExist } from '../shared' 40import { doesExist, throwIfNotValid } from '../shared'
41import { throwIfNotValid } from '../utils'
42import { VideoModel } from './video' 41import { VideoModel } from './video'
43 42
44@Table({ 43@Table({
@@ -138,7 +137,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
138 static doesInfohashExist (infoHash: string) { 137 static doesInfohashExist (infoHash: string) {
139 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1' 138 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
140 139
141 return doesExist(query, { infoHash }) 140 return doesExist(this.sequelize, query, { infoHash })
142 } 141 }
143 142
144 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) { 143 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
@@ -237,7 +236,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
237 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` + 236 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
238 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1` 237 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
239 238
240 return doesExist(query, { videoUUID }) 239 return doesExist(this.sequelize, query, { videoUUID })
241 } 240 }
242 241
243 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) { 242 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 56cc45cfe..1a10d2da2 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -32,7 +32,7 @@ import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFil
32import { VideoPathManager } from '@server/lib/video-path-manager' 32import { VideoPathManager } from '@server/lib/video-path-manager'
33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' 33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
34import { getServerActor } from '@server/models/application/application' 34import { getServerActor } from '@server/models/application/application'
35import { ModelCache } from '@server/models/model-cache' 35import { ModelCache } from '@server/models/shared/model-cache'
36import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils' 36import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
37import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils' 37import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils'
38import { 38import {
@@ -103,10 +103,9 @@ import { VideoRedundancyModel } from '../redundancy/video-redundancy'
103import { ServerModel } from '../server/server' 103import { ServerModel } from '../server/server'
104import { TrackerModel } from '../server/tracker' 104import { TrackerModel } from '../server/tracker'
105import { VideoTrackerModel } from '../server/video-tracker' 105import { VideoTrackerModel } from '../server/video-tracker'
106import { setAsUpdated } from '../shared' 106import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
107import { UserModel } from '../user/user' 107import { UserModel } from '../user/user'
108import { UserVideoHistoryModel } from '../user/user-video-history' 108import { UserVideoHistoryModel } from '../user/user-video-history'
109import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
110import { VideoViewModel } from '../view/video-view' 109import { VideoViewModel } from '../view/video-view'
111import { 110import {
112 videoFilesModelToFormattedJSON, 111 videoFilesModelToFormattedJSON,
@@ -1871,7 +1870,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1871 } 1870 }
1872 1871
1873 setAsRefreshed (transaction?: Transaction) { 1872 setAsRefreshed (transaction?: Transaction) {
1874 return setAsUpdated('video', this.id, transaction) 1873 return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
1875 } 1874 }
1876 1875
1877 // --------------------------------------------------------------------------- 1876 // ---------------------------------------------------------------------------
diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts
index 9d0d89a59..274117e86 100644
--- a/server/models/view/local-video-viewer.ts
+++ b/server/models/view/local-video-viewer.ts
@@ -21,6 +21,10 @@ import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-se
21 indexes: [ 21 indexes: [
22 { 22 {
23 fields: [ 'videoId' ] 23 fields: [ 'videoId' ]
24 },
25 {
26 fields: [ 'url' ],
27 unique: true
24 } 28 }
25 ] 29 ]
26}) 30})
diff --git a/server/tests/api/activitypub/cleaner.ts b/server/tests/api/activitypub/cleaner.ts
index eb6779123..1c1495022 100644
--- a/server/tests/api/activitypub/cleaner.ts
+++ b/server/tests/api/activitypub/cleaner.ts
@@ -148,7 +148,7 @@ describe('Test AP cleaner', function () {
148 it('Should destroy server 3 internal shares and correctly clean them', async function () { 148 it('Should destroy server 3 internal shares and correctly clean them', async function () {
149 this.timeout(20000) 149 this.timeout(20000)
150 150
151 const preCount = await servers[0].sql.getCount('videoShare') 151 const preCount = await servers[0].sql.getVideoShareCount()
152 expect(preCount).to.equal(6) 152 expect(preCount).to.equal(6)
153 153
154 await servers[2].sql.deleteAll('videoShare') 154 await servers[2].sql.deleteAll('videoShare')
@@ -156,7 +156,7 @@ describe('Test AP cleaner', function () {
156 await waitJobs(servers) 156 await waitJobs(servers)
157 157
158 // Still 6 because we don't have remote shares on local videos 158 // Still 6 because we don't have remote shares on local videos
159 const postCount = await servers[0].sql.getCount('videoShare') 159 const postCount = await servers[0].sql.getVideoShareCount()
160 expect(postCount).to.equal(6) 160 expect(postCount).to.equal(6)
161 }) 161 })
162 162
@@ -185,7 +185,7 @@ describe('Test AP cleaner', function () {
185 async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') { 185 async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') {
186 const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` + 186 const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` +
187 `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'` 187 `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'`
188 const res = await servers[0].sql.selectQuery(query) 188 const res = await servers[0].sql.selectQuery<{ url: string }>(query)
189 189
190 for (const rate of res) { 190 for (const rate of res) {
191 const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`) 191 const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`)
@@ -231,7 +231,7 @@ describe('Test AP cleaner', function () {
231 const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` + 231 const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` +
232 `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'` 232 `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'`
233 233
234 const res = await servers[0].sql.selectQuery(query) 234 const res = await servers[0].sql.selectQuery<{ url: string, videoUUID: string }>(query)
235 235
236 for (const comment of res) { 236 for (const comment of res) {
237 const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`) 237 const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`)
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 3415625ca..93a3f3eb9 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -79,6 +79,7 @@ describe('Test config API validators', function () {
79 signup: { 79 signup: {
80 enabled: false, 80 enabled: false,
81 limit: 5, 81 limit: 5,
82 requiresApproval: false,
82 requiresEmailVerification: false, 83 requiresEmailVerification: false,
83 minimumAge: 16 84 minimumAge: 16
84 }, 85 },
@@ -313,6 +314,7 @@ describe('Test config API validators', function () {
313 signup: { 314 signup: {
314 enabled: true, 315 enabled: true,
315 limit: 5, 316 limit: 5,
317 requiresApproval: true,
316 requiresEmailVerification: true 318 requiresEmailVerification: true
317 } 319 }
318 } 320 }
diff --git a/server/tests/api/check-params/contact-form.ts b/server/tests/api/check-params/contact-form.ts
index 7968ef802..f0f8819b9 100644
--- a/server/tests/api/check-params/contact-form.ts
+++ b/server/tests/api/check-params/contact-form.ts
@@ -2,7 +2,14 @@
2 2
3import { MockSmtpServer } from '@server/tests/shared' 3import { MockSmtpServer } from '@server/tests/shared'
4import { HttpStatusCode } from '@shared/models' 4import { HttpStatusCode } from '@shared/models'
5import { cleanupTests, ContactFormCommand, createSingleServer, killallServers, PeerTubeServer } from '@shared/server-commands' 5import {
6 cleanupTests,
7 ConfigCommand,
8 ContactFormCommand,
9 createSingleServer,
10 killallServers,
11 PeerTubeServer
12} from '@shared/server-commands'
6 13
7describe('Test contact form API validators', function () { 14describe('Test contact form API validators', function () {
8 let server: PeerTubeServer 15 let server: PeerTubeServer
@@ -38,7 +45,7 @@ describe('Test contact form API validators', function () {
38 await killallServers([ server ]) 45 await killallServers([ server ])
39 46
40 // Contact form is disabled 47 // Contact form is disabled
41 await server.run({ smtp: { hostname: '127.0.0.1', port: emailPort }, contact_form: { enabled: false } }) 48 await server.run({ ...ConfigCommand.getEmailOverrideConfig(emailPort), contact_form: { enabled: false } })
42 await command.send({ ...defaultBody, expectedStatus: HttpStatusCode.CONFLICT_409 }) 49 await command.send({ ...defaultBody, expectedStatus: HttpStatusCode.CONFLICT_409 })
43 }) 50 })
44 51
@@ -48,7 +55,7 @@ describe('Test contact form API validators', function () {
48 await killallServers([ server ]) 55 await killallServers([ server ])
49 56
50 // Email & contact form enabled 57 // Email & contact form enabled
51 await server.run({ smtp: { hostname: '127.0.0.1', port: emailPort } }) 58 await server.run(ConfigCommand.getEmailOverrideConfig(emailPort))
52 59
53 await command.send({ ...defaultBody, fromEmail: 'badEmail', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 60 await command.send({ ...defaultBody, fromEmail: 'badEmail', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
54 await command.send({ ...defaultBody, fromEmail: 'badEmail@', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 61 await command.send({ ...defaultBody, fromEmail: 'badEmail@', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 961093bb5..ddbcb42f8 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -15,6 +15,7 @@ import './metrics'
15import './my-user' 15import './my-user'
16import './plugins' 16import './plugins'
17import './redundancy' 17import './redundancy'
18import './registrations'
18import './search' 19import './search'
19import './services' 20import './services'
20import './transcoding' 21import './transcoding'
@@ -23,7 +24,7 @@ import './upload-quota'
23import './user-notifications' 24import './user-notifications'
24import './user-subscriptions' 25import './user-subscriptions'
25import './users-admin' 26import './users-admin'
26import './users' 27import './users-emails'
27import './video-blacklist' 28import './video-blacklist'
28import './video-captions' 29import './video-captions'
29import './video-channel-syncs' 30import './video-channel-syncs'
diff --git a/server/tests/api/check-params/redundancy.ts b/server/tests/api/check-params/redundancy.ts
index 908407b9a..73dfd489d 100644
--- a/server/tests/api/check-params/redundancy.ts
+++ b/server/tests/api/check-params/redundancy.ts
@@ -24,7 +24,7 @@ describe('Test server redundancy API validators', function () {
24 // --------------------------------------------------------------- 24 // ---------------------------------------------------------------
25 25
26 before(async function () { 26 before(async function () {
27 this.timeout(80000) 27 this.timeout(160000)
28 28
29 servers = await createMultipleServers(2) 29 servers = await createMultipleServers(2)
30 30
diff --git a/server/tests/api/check-params/registrations.ts b/server/tests/api/check-params/registrations.ts
new file mode 100644
index 000000000..fe16ebd93
--- /dev/null
+++ b/server/tests/api/check-params/registrations.ts
@@ -0,0 +1,433 @@
1import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
2import { omit } from '@shared/core-utils'
3import { HttpStatusCode, UserRole } from '@shared/models'
4import {
5 cleanupTests,
6 createSingleServer,
7 makePostBodyRequest,
8 PeerTubeServer,
9 setAccessTokensToServers,
10 setDefaultAccountAvatar,
11 setDefaultChannelAvatar
12} from '@shared/server-commands'
13
14describe('Test registrations API validators', function () {
15 let server: PeerTubeServer
16 let userToken: string
17 let moderatorToken: string
18
19 // ---------------------------------------------------------------
20
21 before(async function () {
22 this.timeout(30000)
23
24 server = await createSingleServer(1)
25
26 await setAccessTokensToServers([ server ])
27 await setDefaultAccountAvatar([ server ])
28 await setDefaultChannelAvatar([ server ])
29
30 await server.config.enableSignup(false);
31
32 ({ token: moderatorToken } = await server.users.generate('moderator', UserRole.MODERATOR));
33 ({ token: userToken } = await server.users.generate('user', UserRole.USER))
34 })
35
36 describe('Register', function () {
37 const registrationPath = '/api/v1/users/register'
38 const registrationRequestPath = '/api/v1/users/registrations/request'
39
40 const baseCorrectParams = {
41 username: 'user3',
42 displayName: 'super user',
43 email: 'test3@example.com',
44 password: 'my super password',
45 registrationReason: 'my super registration reason'
46 }
47
48 describe('When registering a new user or requesting user registration', function () {
49
50 async function check (fields: any, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
51 await server.config.enableSignup(false)
52 await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus })
53
54 await server.config.enableSignup(true)
55 await makePostBodyRequest({ url: server.url, path: registrationRequestPath, fields, expectedStatus })
56 }
57
58 it('Should fail with a too small username', async function () {
59 const fields = { ...baseCorrectParams, username: '' }
60
61 await check(fields)
62 })
63
64 it('Should fail with a too long username', async function () {
65 const fields = { ...baseCorrectParams, username: 'super'.repeat(50) }
66
67 await check(fields)
68 })
69
70 it('Should fail with an incorrect username', async function () {
71 const fields = { ...baseCorrectParams, username: 'my username' }
72
73 await check(fields)
74 })
75
76 it('Should fail with a missing email', async function () {
77 const fields = omit(baseCorrectParams, [ 'email' ])
78
79 await check(fields)
80 })
81
82 it('Should fail with an invalid email', async function () {
83 const fields = { ...baseCorrectParams, email: 'test_example.com' }
84
85 await check(fields)
86 })
87
88 it('Should fail with a too small password', async function () {
89 const fields = { ...baseCorrectParams, password: 'bla' }
90
91 await check(fields)
92 })
93
94 it('Should fail with a too long password', async function () {
95 const fields = { ...baseCorrectParams, password: 'super'.repeat(61) }
96
97 await check(fields)
98 })
99
100 it('Should fail if we register a user with the same username', async function () {
101 const fields = { ...baseCorrectParams, username: 'root' }
102
103 await check(fields, HttpStatusCode.CONFLICT_409)
104 })
105
106 it('Should fail with a "peertube" username', async function () {
107 const fields = { ...baseCorrectParams, username: 'peertube' }
108
109 await check(fields, HttpStatusCode.CONFLICT_409)
110 })
111
112 it('Should fail if we register a user with the same email', async function () {
113 const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' }
114
115 await check(fields, HttpStatusCode.CONFLICT_409)
116 })
117
118 it('Should fail with a bad display name', async function () {
119 const fields = { ...baseCorrectParams, displayName: 'a'.repeat(150) }
120
121 await check(fields)
122 })
123
124 it('Should fail with a bad channel name', async function () {
125 const fields = { ...baseCorrectParams, channel: { name: '[]azf', displayName: 'toto' } }
126
127 await check(fields)
128 })
129
130 it('Should fail with a bad channel display name', async function () {
131 const fields = { ...baseCorrectParams, channel: { name: 'toto', displayName: '' } }
132
133 await check(fields)
134 })
135
136 it('Should fail with a channel name that is the same as username', async function () {
137 const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } }
138 const fields = { ...baseCorrectParams, ...source }
139
140 await check(fields)
141 })
142
143 it('Should fail with an existing channel', async function () {
144 const attributes = { name: 'existing_channel', displayName: 'hello', description: 'super description' }
145 await server.channels.create({ attributes })
146
147 const fields = { ...baseCorrectParams, channel: { name: 'existing_channel', displayName: 'toto' } }
148
149 await check(fields, HttpStatusCode.CONFLICT_409)
150 })
151
152 it('Should fail on a server with registration disabled', async function () {
153 this.timeout(60000)
154
155 await server.config.updateExistingSubConfig({
156 newConfig: {
157 signup: {
158 enabled: false
159 }
160 }
161 })
162
163 await server.registrations.register({ username: 'user4', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
164 await server.registrations.requestRegistration({
165 username: 'user4',
166 registrationReason: 'reason',
167 expectedStatus: HttpStatusCode.FORBIDDEN_403
168 })
169 })
170
171 it('Should fail if the user limit is reached', async function () {
172 this.timeout(60000)
173
174 const { total } = await server.users.list()
175
176 await server.config.enableSignup(false, total)
177 await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
178
179 await server.config.enableSignup(true, total)
180 await server.registrations.requestRegistration({
181 username: 'user42',
182 registrationReason: 'reason',
183 expectedStatus: HttpStatusCode.FORBIDDEN_403
184 })
185 })
186
187 it('Should succeed if the user limit is not reached', async function () {
188 this.timeout(60000)
189
190 const { total } = await server.users.list()
191
192 await server.config.enableSignup(false, total + 1)
193 await server.registrations.register({ username: 'user43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
194
195 await server.config.enableSignup(true, total + 2)
196 await server.registrations.requestRegistration({
197 username: 'user44',
198 registrationReason: 'reason',
199 expectedStatus: HttpStatusCode.OK_200
200 })
201 })
202 })
203
204 describe('On direct registration', function () {
205
206 it('Should succeed with the correct params', async function () {
207 await server.config.enableSignup(false)
208
209 const fields = {
210 username: 'user_direct_1',
211 displayName: 'super user direct 1',
212 email: 'user_direct_1@example.com',
213 password: 'my super password',
214 channel: { name: 'super_user_direct_1_channel', displayName: 'super user direct 1 channel' }
215 }
216
217 await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus: HttpStatusCode.NO_CONTENT_204 })
218 })
219
220 it('Should fail if the instance requires approval', async function () {
221 this.timeout(60000)
222
223 await server.config.enableSignup(true)
224 await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
225 })
226 })
227
228 describe('On registration request', function () {
229
230 before(async function () {
231 this.timeout(60000)
232
233 await server.config.enableSignup(true)
234 })
235
236 it('Should fail with an invalid registration reason', async function () {
237 for (const registrationReason of [ '', 't', 't'.repeat(5000) ]) {
238 await server.registrations.requestRegistration({
239 username: 'user_request_1',
240 registrationReason,
241 expectedStatus: HttpStatusCode.BAD_REQUEST_400
242 })
243 }
244 })
245
246 it('Should succeed with the correct params', async function () {
247 await server.registrations.requestRegistration({
248 username: 'user_request_2',
249 registrationReason: 'tt',
250 channel: {
251 displayName: 'my user request 2 channel',
252 name: 'user_request_2_channel'
253 }
254 })
255 })
256
257 it('Should fail if the user is already awaiting registration approval', async function () {
258 await server.registrations.requestRegistration({
259 username: 'user_request_2',
260 registrationReason: 'tt',
261 channel: {
262 displayName: 'my user request 42 channel',
263 name: 'user_request_42_channel'
264 },
265 expectedStatus: HttpStatusCode.CONFLICT_409
266 })
267 })
268
269 it('Should fail if the channel is already awaiting registration approval', async function () {
270 await server.registrations.requestRegistration({
271 username: 'user42',
272 registrationReason: 'tt',
273 channel: {
274 displayName: 'my user request 2 channel',
275 name: 'user_request_2_channel'
276 },
277 expectedStatus: HttpStatusCode.CONFLICT_409
278 })
279 })
280
281 it('Should fail if the instance does not require approval', async function () {
282 this.timeout(60000)
283
284 await server.config.enableSignup(false)
285
286 await server.registrations.requestRegistration({
287 username: 'user42',
288 registrationReason: 'toto',
289 expectedStatus: HttpStatusCode.BAD_REQUEST_400
290 })
291 })
292 })
293 })
294
295 describe('Registrations accept/reject', function () {
296 let id1: number
297 let id2: number
298
299 before(async function () {
300 this.timeout(60000)
301
302 await server.config.enableSignup(true);
303
304 ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_2', registrationReason: 'toto' }));
305 ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_3', registrationReason: 'toto' }))
306 })
307
308 it('Should fail to accept/reject registration without token', async function () {
309 const options = { id: id1, moderationResponse: 'tt', token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }
310 await server.registrations.accept(options)
311 await server.registrations.reject(options)
312 })
313
314 it('Should fail to accept/reject registration with a non moderator user', async function () {
315 const options = { id: id1, moderationResponse: 'tt', token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }
316 await server.registrations.accept(options)
317 await server.registrations.reject(options)
318 })
319
320 it('Should fail to accept/reject registration with a bad registration id', async function () {
321 {
322 const options = { id: 't' as any, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
323 await server.registrations.accept(options)
324 await server.registrations.reject(options)
325 }
326
327 {
328 const options = { id: 42, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
329 await server.registrations.accept(options)
330 await server.registrations.reject(options)
331 }
332 })
333
334 it('Should fail to accept/reject registration with a bad moderation resposne', async function () {
335 for (const moderationResponse of [ '', 't', 't'.repeat(5000) ]) {
336 const options = { id: id1, moderationResponse, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
337 await server.registrations.accept(options)
338 await server.registrations.reject(options)
339 }
340 })
341
342 it('Should succeed to accept a registration', async function () {
343 await server.registrations.accept({ id: id1, moderationResponse: 'tt', token: moderatorToken })
344 })
345
346 it('Should succeed to reject a registration', async function () {
347 await server.registrations.reject({ id: id2, moderationResponse: 'tt', token: moderatorToken })
348 })
349
350 it('Should fail to accept/reject a registration that was already accepted/rejected', async function () {
351 for (const id of [ id1, id2 ]) {
352 const options = { id, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.CONFLICT_409 }
353 await server.registrations.accept(options)
354 await server.registrations.reject(options)
355 }
356 })
357 })
358
359 describe('Registrations deletion', function () {
360 let id1: number
361 let id2: number
362 let id3: number
363
364 before(async function () {
365 ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_4', registrationReason: 'toto' }));
366 ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_5', registrationReason: 'toto' }));
367 ({ id: id3 } = await server.registrations.requestRegistration({ username: 'request_6', registrationReason: 'toto' }))
368
369 await server.registrations.accept({ id: id2, moderationResponse: 'tt' })
370 await server.registrations.reject({ id: id3, moderationResponse: 'tt' })
371 })
372
373 it('Should fail to delete registration without token', async function () {
374 await server.registrations.delete({ id: id1, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
375 })
376
377 it('Should fail to delete registration with a non moderator user', async function () {
378 await server.registrations.delete({ id: id1, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
379 })
380
381 it('Should fail to delete registration with a bad registration id', async function () {
382 await server.registrations.delete({ id: 't' as any, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
383 await server.registrations.delete({ id: 42, token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
384 })
385
386 it('Should succeed with the correct params', async function () {
387 await server.registrations.delete({ id: id1, token: moderatorToken })
388 await server.registrations.delete({ id: id2, token: moderatorToken })
389 await server.registrations.delete({ id: id3, token: moderatorToken })
390 })
391 })
392
393 describe('Listing registrations', function () {
394 const path = '/api/v1/users/registrations'
395
396 it('Should fail with a bad start pagination', async function () {
397 await checkBadStartPagination(server.url, path, server.accessToken)
398 })
399
400 it('Should fail with a bad count pagination', async function () {
401 await checkBadCountPagination(server.url, path, server.accessToken)
402 })
403
404 it('Should fail with an incorrect sort', async function () {
405 await checkBadSortPagination(server.url, path, server.accessToken)
406 })
407
408 it('Should fail with a non authenticated user', async function () {
409 await server.registrations.list({
410 token: null,
411 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
412 })
413 })
414
415 it('Should fail with a non admin user', async function () {
416 await server.registrations.list({
417 token: userToken,
418 expectedStatus: HttpStatusCode.FORBIDDEN_403
419 })
420 })
421
422 it('Should succeed with the correct params', async function () {
423 await server.registrations.list({
424 token: moderatorToken,
425 search: 'toto'
426 })
427 })
428 })
429
430 after(async function () {
431 await cleanupTests([ server ])
432 })
433})
diff --git a/server/tests/api/check-params/upload-quota.ts b/server/tests/api/check-params/upload-quota.ts
index 70e6f4af9..fdc711bd5 100644
--- a/server/tests/api/check-params/upload-quota.ts
+++ b/server/tests/api/check-params/upload-quota.ts
@@ -42,7 +42,7 @@ describe('Test upload quota', function () {
42 this.timeout(30000) 42 this.timeout(30000)
43 43
44 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' } 44 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
45 await server.users.register(user) 45 await server.registrations.register(user)
46 const userToken = await server.login.getAccessToken(user) 46 const userToken = await server.login.getAccessToken(user)
47 47
48 const attributes = { fixture: 'video_short2.webm' } 48 const attributes = { fixture: 'video_short2.webm' }
@@ -57,7 +57,7 @@ describe('Test upload quota', function () {
57 this.timeout(30000) 57 this.timeout(30000)
58 58
59 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' } 59 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
60 await server.users.register(user) 60 await server.registrations.register(user)
61 const userToken = await server.login.getAccessToken(user) 61 const userToken = await server.login.getAccessToken(user)
62 62
63 const attributes = { fixture: 'video_short2.webm' } 63 const attributes = { fixture: 'video_short2.webm' }
diff --git a/server/tests/api/check-params/users-admin.ts b/server/tests/api/check-params/users-admin.ts
index 7ba709c4a..be2496bb4 100644
--- a/server/tests/api/check-params/users-admin.ts
+++ b/server/tests/api/check-params/users-admin.ts
@@ -5,6 +5,7 @@ import { omit } from '@shared/core-utils'
5import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models' 5import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 ConfigCommand,
8 createSingleServer, 9 createSingleServer,
9 killallServers, 10 killallServers,
10 makeGetRequest, 11 makeGetRequest,
@@ -156,13 +157,7 @@ describe('Test users admin API validators', function () {
156 157
157 await killallServers([ server ]) 158 await killallServers([ server ])
158 159
159 const config = { 160 await server.run(ConfigCommand.getEmailOverrideConfig(emailPort))
160 smtp: {
161 hostname: '127.0.0.1',
162 port: emailPort
163 }
164 }
165 await server.run(config)
166 161
167 const fields = { 162 const fields = {
168 ...baseCorrectParams, 163 ...baseCorrectParams,
diff --git a/server/tests/api/check-params/users-emails.ts b/server/tests/api/check-params/users-emails.ts
new file mode 100644
index 000000000..8cfb1d15f
--- /dev/null
+++ b/server/tests/api/check-params/users-emails.ts
@@ -0,0 +1,119 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2import { MockSmtpServer } from '@server/tests/shared'
3import { HttpStatusCode, UserRole } from '@shared/models'
4import { cleanupTests, createSingleServer, makePostBodyRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
5
6describe('Test users API validators', function () {
7 let server: PeerTubeServer
8
9 // ---------------------------------------------------------------
10
11 before(async function () {
12 this.timeout(30000)
13
14 server = await createSingleServer(1, {
15 rates_limit: {
16 ask_send_email: {
17 max: 10
18 }
19 }
20 })
21
22 await setAccessTokensToServers([ server ])
23 await server.config.enableSignup(true)
24
25 await server.users.generate('moderator2', UserRole.MODERATOR)
26
27 await server.registrations.requestRegistration({
28 username: 'request1',
29 registrationReason: 'tt'
30 })
31 })
32
33 describe('When asking a password reset', function () {
34 const path = '/api/v1/users/ask-reset-password'
35
36 it('Should fail with a missing email', async function () {
37 const fields = {}
38
39 await makePostBodyRequest({ url: server.url, path, fields })
40 })
41
42 it('Should fail with an invalid email', async function () {
43 const fields = { email: 'hello' }
44
45 await makePostBodyRequest({ url: server.url, path, fields })
46 })
47
48 it('Should success with the correct params', async function () {
49 const fields = { email: 'admin@example.com' }
50
51 await makePostBodyRequest({
52 url: server.url,
53 path,
54 fields,
55 expectedStatus: HttpStatusCode.NO_CONTENT_204
56 })
57 })
58 })
59
60 describe('When asking for an account verification email', function () {
61 const path = '/api/v1/users/ask-send-verify-email'
62
63 it('Should fail with a missing email', async function () {
64 const fields = {}
65
66 await makePostBodyRequest({ url: server.url, path, fields })
67 })
68
69 it('Should fail with an invalid email', async function () {
70 const fields = { email: 'hello' }
71
72 await makePostBodyRequest({ url: server.url, path, fields })
73 })
74
75 it('Should succeed with the correct params', async function () {
76 const fields = { email: 'admin@example.com' }
77
78 await makePostBodyRequest({
79 url: server.url,
80 path,
81 fields,
82 expectedStatus: HttpStatusCode.NO_CONTENT_204
83 })
84 })
85 })
86
87 describe('When asking for a registration verification email', function () {
88 const path = '/api/v1/users/registrations/ask-send-verify-email'
89
90 it('Should fail with a missing email', async function () {
91 const fields = {}
92
93 await makePostBodyRequest({ url: server.url, path, fields })
94 })
95
96 it('Should fail with an invalid email', async function () {
97 const fields = { email: 'hello' }
98
99 await makePostBodyRequest({ url: server.url, path, fields })
100 })
101
102 it('Should succeed with the correct params', async function () {
103 const fields = { email: 'request1@example.com' }
104
105 await makePostBodyRequest({
106 url: server.url,
107 path,
108 fields,
109 expectedStatus: HttpStatusCode.NO_CONTENT_204
110 })
111 })
112 })
113
114 after(async function () {
115 MockSmtpServer.Instance.kill()
116
117 await cleanupTests([ server ])
118 })
119})
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
deleted file mode 100644
index 7acfd8c2c..000000000
--- a/server/tests/api/check-params/users.ts
+++ /dev/null
@@ -1,255 +0,0 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2import { MockSmtpServer } from '@server/tests/shared'
3import { omit } from '@shared/core-utils'
4import { HttpStatusCode, UserRole } from '@shared/models'
5import { cleanupTests, createSingleServer, makePostBodyRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
6
7describe('Test users API validators', function () {
8 const path = '/api/v1/users/'
9 let server: PeerTubeServer
10 let serverWithRegistrationDisabled: PeerTubeServer
11
12 // ---------------------------------------------------------------
13
14 before(async function () {
15 this.timeout(30000)
16
17 const res = await Promise.all([
18 createSingleServer(1, { signup: { limit: 3 } }),
19 createSingleServer(2)
20 ])
21
22 server = res[0]
23 serverWithRegistrationDisabled = res[1]
24
25 await setAccessTokensToServers([ server ])
26
27 await server.users.generate('moderator2', UserRole.MODERATOR)
28 })
29
30 describe('When registering a new user', function () {
31 const registrationPath = path + '/register'
32 const baseCorrectParams = {
33 username: 'user3',
34 displayName: 'super user',
35 email: 'test3@example.com',
36 password: 'my super password'
37 }
38
39 it('Should fail with a too small username', async function () {
40 const fields = { ...baseCorrectParams, username: '' }
41
42 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
43 })
44
45 it('Should fail with a too long username', async function () {
46 const fields = { ...baseCorrectParams, username: 'super'.repeat(50) }
47
48 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
49 })
50
51 it('Should fail with an incorrect username', async function () {
52 const fields = { ...baseCorrectParams, username: 'my username' }
53
54 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
55 })
56
57 it('Should fail with a missing email', async function () {
58 const fields = omit(baseCorrectParams, [ 'email' ])
59
60 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
61 })
62
63 it('Should fail with an invalid email', async function () {
64 const fields = { ...baseCorrectParams, email: 'test_example.com' }
65
66 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
67 })
68
69 it('Should fail with a too small password', async function () {
70 const fields = { ...baseCorrectParams, password: 'bla' }
71
72 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
73 })
74
75 it('Should fail with a too long password', async function () {
76 const fields = { ...baseCorrectParams, password: 'super'.repeat(61) }
77
78 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
79 })
80
81 it('Should fail if we register a user with the same username', async function () {
82 const fields = { ...baseCorrectParams, username: 'root' }
83
84 await makePostBodyRequest({
85 url: server.url,
86 path: registrationPath,
87 token: server.accessToken,
88 fields,
89 expectedStatus: HttpStatusCode.CONFLICT_409
90 })
91 })
92
93 it('Should fail with a "peertube" username', async function () {
94 const fields = { ...baseCorrectParams, username: 'peertube' }
95
96 await makePostBodyRequest({
97 url: server.url,
98 path: registrationPath,
99 token: server.accessToken,
100 fields,
101 expectedStatus: HttpStatusCode.CONFLICT_409
102 })
103 })
104
105 it('Should fail if we register a user with the same email', async function () {
106 const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' }
107
108 await makePostBodyRequest({
109 url: server.url,
110 path: registrationPath,
111 token: server.accessToken,
112 fields,
113 expectedStatus: HttpStatusCode.CONFLICT_409
114 })
115 })
116
117 it('Should fail with a bad display name', async function () {
118 const fields = { ...baseCorrectParams, displayName: 'a'.repeat(150) }
119
120 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
121 })
122
123 it('Should fail with a bad channel name', async function () {
124 const fields = { ...baseCorrectParams, channel: { name: '[]azf', displayName: 'toto' } }
125
126 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
127 })
128
129 it('Should fail with a bad channel display name', async function () {
130 const fields = { ...baseCorrectParams, channel: { name: 'toto', displayName: '' } }
131
132 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
133 })
134
135 it('Should fail with a channel name that is the same as username', async function () {
136 const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } }
137 const fields = { ...baseCorrectParams, ...source }
138
139 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
140 })
141
142 it('Should fail with an existing channel', async function () {
143 const attributes = { name: 'existing_channel', displayName: 'hello', description: 'super description' }
144 await server.channels.create({ attributes })
145
146 const fields = { ...baseCorrectParams, channel: { name: 'existing_channel', displayName: 'toto' } }
147
148 await makePostBodyRequest({
149 url: server.url,
150 path: registrationPath,
151 token: server.accessToken,
152 fields,
153 expectedStatus: HttpStatusCode.CONFLICT_409
154 })
155 })
156
157 it('Should succeed with the correct params', async function () {
158 const fields = { ...baseCorrectParams, channel: { name: 'super_channel', displayName: 'toto' } }
159
160 await makePostBodyRequest({
161 url: server.url,
162 path: registrationPath,
163 token: server.accessToken,
164 fields,
165 expectedStatus: HttpStatusCode.NO_CONTENT_204
166 })
167 })
168
169 it('Should fail on a server with registration disabled', async function () {
170 const fields = {
171 username: 'user4',
172 email: 'test4@example.com',
173 password: 'my super password 4'
174 }
175
176 await makePostBodyRequest({
177 url: serverWithRegistrationDisabled.url,
178 path: registrationPath,
179 token: serverWithRegistrationDisabled.accessToken,
180 fields,
181 expectedStatus: HttpStatusCode.FORBIDDEN_403
182 })
183 })
184 })
185
186 describe('When registering multiple users on a server with users limit', function () {
187
188 it('Should fail when after 3 registrations', async function () {
189 await server.users.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
190 })
191
192 })
193
194 describe('When asking a password reset', function () {
195 const path = '/api/v1/users/ask-reset-password'
196
197 it('Should fail with a missing email', async function () {
198 const fields = {}
199
200 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
201 })
202
203 it('Should fail with an invalid email', async function () {
204 const fields = { email: 'hello' }
205
206 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
207 })
208
209 it('Should success with the correct params', async function () {
210 const fields = { email: 'admin@example.com' }
211
212 await makePostBodyRequest({
213 url: server.url,
214 path,
215 token: server.accessToken,
216 fields,
217 expectedStatus: HttpStatusCode.NO_CONTENT_204
218 })
219 })
220 })
221
222 describe('When asking for an account verification email', function () {
223 const path = '/api/v1/users/ask-send-verify-email'
224
225 it('Should fail with a missing email', async function () {
226 const fields = {}
227
228 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
229 })
230
231 it('Should fail with an invalid email', async function () {
232 const fields = { email: 'hello' }
233
234 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
235 })
236
237 it('Should succeed with the correct params', async function () {
238 const fields = { email: 'admin@example.com' }
239
240 await makePostBodyRequest({
241 url: server.url,
242 path,
243 token: server.accessToken,
244 fields,
245 expectedStatus: HttpStatusCode.NO_CONTENT_204
246 })
247 })
248 })
249
250 after(async function () {
251 MockSmtpServer.Instance.kill()
252
253 await cleanupTests([ server, serverWithRegistrationDisabled ])
254 })
255})
diff --git a/server/tests/api/live/live-fast-restream.ts b/server/tests/api/live/live-fast-restream.ts
index c0bb8d529..f6959b83c 100644
--- a/server/tests/api/live/live-fast-restream.ts
+++ b/server/tests/api/live/live-fast-restream.ts
@@ -78,9 +78,15 @@ describe('Fast restream in live', function () {
78 const video = await server.videos.get({ id: liveId }) 78 const video = await server.videos.get({ id: liveId })
79 expect(video.streamingPlaylists).to.have.lengthOf(1) 79 expect(video.streamingPlaylists).to.have.lengthOf(1)
80 80
81 await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 }) 81 try {
82 await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) 82 await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
83 await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) 83 await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
84 await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
85 } catch (err) {
86 // FIXME: try to debug error in CI "Unexpected end of JSON input"
87 console.error(err)
88 throw err
89 }
84 90
85 await wait(100) 91 await wait(100)
86 } 92 }
@@ -129,7 +135,7 @@ describe('Fast restream in live', function () {
129 await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' }) 135 await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
130 }) 136 })
131 137
132 it('Should correctly fast reastream in a permanent live with and without save replay', async function () { 138 it('Should correctly fast restream in a permanent live with and without save replay', async function () {
133 this.timeout(480000) 139 this.timeout(480000)
134 140
135 // A test can take a long time, so prefer to run them in parallel 141 // A test can take a long time, so prefer to run them in parallel
diff --git a/server/tests/api/notifications/index.ts b/server/tests/api/notifications/index.ts
index 8caa30a3d..c0216b74f 100644
--- a/server/tests/api/notifications/index.ts
+++ b/server/tests/api/notifications/index.ts
@@ -2,4 +2,5 @@ import './admin-notifications'
2import './comments-notifications' 2import './comments-notifications'
3import './moderation-notifications' 3import './moderation-notifications'
4import './notifications-api' 4import './notifications-api'
5import './registrations-notifications'
5import './user-notifications' 6import './user-notifications'
diff --git a/server/tests/api/notifications/moderation-notifications.ts b/server/tests/api/notifications/moderation-notifications.ts
index b127a7a31..bb11a08aa 100644
--- a/server/tests/api/notifications/moderation-notifications.ts
+++ b/server/tests/api/notifications/moderation-notifications.ts
@@ -11,7 +11,6 @@ import {
11 checkNewInstanceFollower, 11 checkNewInstanceFollower,
12 checkNewVideoAbuseForModerators, 12 checkNewVideoAbuseForModerators,
13 checkNewVideoFromSubscription, 13 checkNewVideoFromSubscription,
14 checkUserRegistered,
15 checkVideoAutoBlacklistForModerators, 14 checkVideoAutoBlacklistForModerators,
16 checkVideoIsPublished, 15 checkVideoIsPublished,
17 MockInstancesIndex, 16 MockInstancesIndex,
@@ -34,7 +33,7 @@ describe('Test moderation notifications', function () {
34 let emails: object[] = [] 33 let emails: object[] = []
35 34
36 before(async function () { 35 before(async function () {
37 this.timeout(120000) 36 this.timeout(50000)
38 37
39 const res = await prepareNotificationsTest(3) 38 const res = await prepareNotificationsTest(3)
40 emails = res.emails 39 emails = res.emails
@@ -60,7 +59,7 @@ describe('Test moderation notifications', function () {
60 }) 59 })
61 60
62 it('Should not send a notification to moderators on local abuse reported by an admin', async function () { 61 it('Should not send a notification to moderators on local abuse reported by an admin', async function () {
63 this.timeout(20000) 62 this.timeout(50000)
64 63
65 const name = 'video for abuse ' + buildUUID() 64 const name = 'video for abuse ' + buildUUID()
66 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 65 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -72,7 +71,7 @@ describe('Test moderation notifications', function () {
72 }) 71 })
73 72
74 it('Should send a notification to moderators on local video abuse', async function () { 73 it('Should send a notification to moderators on local video abuse', async function () {
75 this.timeout(20000) 74 this.timeout(50000)
76 75
77 const name = 'video for abuse ' + buildUUID() 76 const name = 'video for abuse ' + buildUUID()
78 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 77 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -84,7 +83,7 @@ describe('Test moderation notifications', function () {
84 }) 83 })
85 84
86 it('Should send a notification to moderators on remote video abuse', async function () { 85 it('Should send a notification to moderators on remote video abuse', async function () {
87 this.timeout(20000) 86 this.timeout(50000)
88 87
89 const name = 'video for abuse ' + buildUUID() 88 const name = 'video for abuse ' + buildUUID()
90 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 89 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -99,7 +98,7 @@ describe('Test moderation notifications', function () {
99 }) 98 })
100 99
101 it('Should send a notification to moderators on local comment abuse', async function () { 100 it('Should send a notification to moderators on local comment abuse', async function () {
102 this.timeout(20000) 101 this.timeout(50000)
103 102
104 const name = 'video for abuse ' + buildUUID() 103 const name = 'video for abuse ' + buildUUID()
105 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 104 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -118,7 +117,7 @@ describe('Test moderation notifications', function () {
118 }) 117 })
119 118
120 it('Should send a notification to moderators on remote comment abuse', async function () { 119 it('Should send a notification to moderators on remote comment abuse', async function () {
121 this.timeout(20000) 120 this.timeout(50000)
122 121
123 const name = 'video for abuse ' + buildUUID() 122 const name = 'video for abuse ' + buildUUID()
124 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 123 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -140,7 +139,7 @@ describe('Test moderation notifications', function () {
140 }) 139 })
141 140
142 it('Should send a notification to moderators on local account abuse', async function () { 141 it('Should send a notification to moderators on local account abuse', async function () {
143 this.timeout(20000) 142 this.timeout(50000)
144 143
145 const username = 'user' + new Date().getTime() 144 const username = 'user' + new Date().getTime()
146 const { account } = await servers[0].users.create({ username, password: 'donald' }) 145 const { account } = await servers[0].users.create({ username, password: 'donald' })
@@ -153,7 +152,7 @@ describe('Test moderation notifications', function () {
153 }) 152 })
154 153
155 it('Should send a notification to moderators on remote account abuse', async function () { 154 it('Should send a notification to moderators on remote account abuse', async function () {
156 this.timeout(20000) 155 this.timeout(50000)
157 156
158 const username = 'user' + new Date().getTime() 157 const username = 'user' + new Date().getTime()
159 const tmpToken = await servers[0].users.generateUserAndToken(username) 158 const tmpToken = await servers[0].users.generateUserAndToken(username)
@@ -327,32 +326,6 @@ describe('Test moderation notifications', function () {
327 }) 326 })
328 }) 327 })
329 328
330 describe('New registration', function () {
331 let baseParams: CheckerBaseParams
332
333 before(() => {
334 baseParams = {
335 server: servers[0],
336 emails,
337 socketNotifications: adminNotifications,
338 token: servers[0].accessToken
339 }
340 })
341
342 it('Should send a notification only to moderators when a user registers on the instance', async function () {
343 this.timeout(10000)
344
345 await servers[0].users.register({ username: 'user_45' })
346
347 await waitJobs(servers)
348
349 await checkUserRegistered({ ...baseParams, username: 'user_45', checkType: 'presence' })
350
351 const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
352 await checkUserRegistered({ ...baseParams, ...userOverride, username: 'user_45', checkType: 'absence' })
353 })
354 })
355
356 describe('New instance follows', function () { 329 describe('New instance follows', function () {
357 const instanceIndexServer = new MockInstancesIndex() 330 const instanceIndexServer = new MockInstancesIndex()
358 let config: any 331 let config: any
@@ -512,10 +485,14 @@ describe('Test moderation notifications', function () {
512 }) 485 })
513 486
514 it('Should not send video publish notification if auto-blacklisted', async function () { 487 it('Should not send video publish notification if auto-blacklisted', async function () {
488 this.timeout(120000)
489
515 await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' }) 490 await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' })
516 }) 491 })
517 492
518 it('Should not send a local user subscription notification if auto-blacklisted', async function () { 493 it('Should not send a local user subscription notification if auto-blacklisted', async function () {
494 this.timeout(120000)
495
519 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' }) 496 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' })
520 }) 497 })
521 498
@@ -524,7 +501,7 @@ describe('Test moderation notifications', function () {
524 }) 501 })
525 502
526 it('Should send video published and unblacklist after video unblacklisted', async function () { 503 it('Should send video published and unblacklist after video unblacklisted', async function () {
527 this.timeout(40000) 504 this.timeout(120000)
528 505
529 await servers[0].blacklist.remove({ videoId: uuid }) 506 await servers[0].blacklist.remove({ videoId: uuid })
530 507
@@ -537,10 +514,14 @@ describe('Test moderation notifications', function () {
537 }) 514 })
538 515
539 it('Should send a local user subscription notification after removed from blacklist', async function () { 516 it('Should send a local user subscription notification after removed from blacklist', async function () {
517 this.timeout(120000)
518
540 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' }) 519 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' })
541 }) 520 })
542 521
543 it('Should send a remote user subscription notification after removed from blacklist', async function () { 522 it('Should send a remote user subscription notification after removed from blacklist', async function () {
523 this.timeout(120000)
524
544 await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' }) 525 await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' })
545 }) 526 })
546 527
@@ -576,7 +557,7 @@ describe('Test moderation notifications', function () {
576 }) 557 })
577 558
578 it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () { 559 it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () {
579 this.timeout(40000) 560 this.timeout(120000)
580 561
581 // In 2 seconds 562 // In 2 seconds
582 const updateAt = new Date(new Date().getTime() + 2000) 563 const updateAt = new Date(new Date().getTime() + 2000)
diff --git a/server/tests/api/notifications/registrations-notifications.ts b/server/tests/api/notifications/registrations-notifications.ts
new file mode 100644
index 000000000..b5a7c2bb5
--- /dev/null
+++ b/server/tests/api/notifications/registrations-notifications.ts
@@ -0,0 +1,88 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import {
4 CheckerBaseParams,
5 checkRegistrationRequest,
6 checkUserRegistered,
7 MockSmtpServer,
8 prepareNotificationsTest
9} from '@server/tests/shared'
10import { UserNotification } from '@shared/models'
11import { cleanupTests, PeerTubeServer, waitJobs } from '@shared/server-commands'
12
13describe('Test registrations notifications', function () {
14 let server: PeerTubeServer
15 let userToken1: string
16
17 let userNotifications: UserNotification[] = []
18 let adminNotifications: UserNotification[] = []
19 let emails: object[] = []
20
21 let baseParams: CheckerBaseParams
22
23 before(async function () {
24 this.timeout(50000)
25
26 const res = await prepareNotificationsTest(1)
27
28 server = res.servers[0]
29 emails = res.emails
30 userToken1 = res.userAccessToken
31 adminNotifications = res.adminNotifications
32 userNotifications = res.userNotifications
33
34 baseParams = {
35 server,
36 emails,
37 socketNotifications: adminNotifications,
38 token: server.accessToken
39 }
40 })
41
42 describe('New direct registration for moderators', function () {
43
44 before(async function () {
45 await server.config.enableSignup(false)
46 })
47
48 it('Should send a notification only to moderators when a user registers on the instance', async function () {
49 this.timeout(50000)
50
51 await server.registrations.register({ username: 'user_10' })
52
53 await waitJobs([ server ])
54
55 await checkUserRegistered({ ...baseParams, username: 'user_10', checkType: 'presence' })
56
57 const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
58 await checkUserRegistered({ ...baseParams, ...userOverride, username: 'user_10', checkType: 'absence' })
59 })
60 })
61
62 describe('New registration request for moderators', function () {
63
64 before(async function () {
65 await server.config.enableSignup(true)
66 })
67
68 it('Should send a notification on new registration request', async function () {
69 this.timeout(50000)
70
71 const registrationReason = 'my reason'
72 await server.registrations.requestRegistration({ username: 'user_11', registrationReason })
73
74 await waitJobs([ server ])
75
76 await checkRegistrationRequest({ ...baseParams, username: 'user_11', registrationReason, checkType: 'presence' })
77
78 const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
79 await checkRegistrationRequest({ ...baseParams, ...userOverride, username: 'user_11', registrationReason, checkType: 'absence' })
80 })
81 })
82
83 after(async function () {
84 MockSmtpServer.Instance.kill()
85
86 await cleanupTests([ server ])
87 })
88})
diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts
index 71ad35a43..869d437d5 100644
--- a/server/tests/api/object-storage/video-static-file-privacy.ts
+++ b/server/tests/api/object-storage/video-static-file-privacy.ts
@@ -120,7 +120,7 @@ describe('Object storage for video static file privacy', function () {
120 // --------------------------------------------------------------------------- 120 // ---------------------------------------------------------------------------
121 121
122 it('Should upload a private video and have appropriate object storage ACL', async function () { 122 it('Should upload a private video and have appropriate object storage ACL', async function () {
123 this.timeout(60000) 123 this.timeout(120000)
124 124
125 { 125 {
126 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 126 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
@@ -138,7 +138,7 @@ describe('Object storage for video static file privacy', function () {
138 }) 138 })
139 139
140 it('Should upload a public video and have appropriate object storage ACL', async function () { 140 it('Should upload a public video and have appropriate object storage ACL', async function () {
141 this.timeout(60000) 141 this.timeout(120000)
142 142
143 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED }) 143 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED })
144 await waitJobs([ server ]) 144 await waitJobs([ server ])
diff --git a/server/tests/api/server/config-defaults.ts b/server/tests/api/server/config-defaults.ts
index 4fa37d0e2..d3b3a2447 100644
--- a/server/tests/api/server/config-defaults.ts
+++ b/server/tests/api/server/config-defaults.ts
@@ -149,7 +149,7 @@ describe('Test config defaults', function () {
149 }) 149 })
150 150
151 it('Should register a user with this default setting', async function () { 151 it('Should register a user with this default setting', async function () {
152 await server.users.register({ username: 'user_p2p_2' }) 152 await server.registrations.register({ username: 'user_p2p_2' })
153 153
154 const userToken = await server.login.getAccessToken('user_p2p_2') 154 const userToken = await server.login.getAccessToken('user_p2p_2')
155 155
@@ -194,7 +194,7 @@ describe('Test config defaults', function () {
194 }) 194 })
195 195
196 it('Should register a user with this default setting', async function () { 196 it('Should register a user with this default setting', async function () {
197 await server.users.register({ username: 'user_p2p_4' }) 197 await server.registrations.register({ username: 'user_p2p_4' })
198 198
199 const userToken = await server.login.getAccessToken('user_p2p_4') 199 const userToken = await server.login.getAccessToken('user_p2p_4')
200 200
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 22446fe0c..b91519660 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -50,6 +50,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
50 expect(data.signup.enabled).to.be.true 50 expect(data.signup.enabled).to.be.true
51 expect(data.signup.limit).to.equal(4) 51 expect(data.signup.limit).to.equal(4)
52 expect(data.signup.minimumAge).to.equal(16) 52 expect(data.signup.minimumAge).to.equal(16)
53 expect(data.signup.requiresApproval).to.be.false
53 expect(data.signup.requiresEmailVerification).to.be.false 54 expect(data.signup.requiresEmailVerification).to.be.false
54 55
55 expect(data.admin.email).to.equal('admin' + server.internalServerNumber + '@example.com') 56 expect(data.admin.email).to.equal('admin' + server.internalServerNumber + '@example.com')
@@ -152,6 +153,7 @@ function checkUpdatedConfig (data: CustomConfig) {
152 153
153 expect(data.signup.enabled).to.be.false 154 expect(data.signup.enabled).to.be.false
154 expect(data.signup.limit).to.equal(5) 155 expect(data.signup.limit).to.equal(5)
156 expect(data.signup.requiresApproval).to.be.false
155 expect(data.signup.requiresEmailVerification).to.be.false 157 expect(data.signup.requiresEmailVerification).to.be.false
156 expect(data.signup.minimumAge).to.equal(10) 158 expect(data.signup.minimumAge).to.equal(10)
157 159
@@ -285,6 +287,7 @@ const newCustomConfig: CustomConfig = {
285 signup: { 287 signup: {
286 enabled: false, 288 enabled: false,
287 limit: 5, 289 limit: 5,
290 requiresApproval: false,
288 requiresEmailVerification: false, 291 requiresEmailVerification: false,
289 minimumAge: 10 292 minimumAge: 10
290 }, 293 },
@@ -468,9 +471,9 @@ describe('Test config', function () {
468 this.timeout(5000) 471 this.timeout(5000)
469 472
470 await Promise.all([ 473 await Promise.all([
471 server.users.register({ username: 'user1' }), 474 server.registrations.register({ username: 'user1' }),
472 server.users.register({ username: 'user2' }), 475 server.registrations.register({ username: 'user2' }),
473 server.users.register({ username: 'user3' }) 476 server.registrations.register({ username: 'user3' })
474 ]) 477 ])
475 478
476 const data = await server.config.getConfig() 479 const data = await server.config.getConfig()
diff --git a/server/tests/api/server/contact-form.ts b/server/tests/api/server/contact-form.ts
index 325218008..dd971203a 100644
--- a/server/tests/api/server/contact-form.ts
+++ b/server/tests/api/server/contact-form.ts
@@ -6,6 +6,7 @@ import { wait } from '@shared/core-utils'
6import { HttpStatusCode } from '@shared/models' 6import { HttpStatusCode } from '@shared/models'
7import { 7import {
8 cleanupTests, 8 cleanupTests,
9 ConfigCommand,
9 ContactFormCommand, 10 ContactFormCommand,
10 createSingleServer, 11 createSingleServer,
11 PeerTubeServer, 12 PeerTubeServer,
@@ -23,13 +24,7 @@ describe('Test contact form', function () {
23 24
24 const port = await MockSmtpServer.Instance.collectEmails(emails) 25 const port = await MockSmtpServer.Instance.collectEmails(emails)
25 26
26 const overrideConfig = { 27 server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port))
27 smtp: {
28 hostname: '127.0.0.1',
29 port
30 }
31 }
32 server = await createSingleServer(1, overrideConfig)
33 await setAccessTokensToServers([ server ]) 28 await setAccessTokensToServers([ server ])
34 29
35 command = server.contactForm 30 command = server.contactForm
diff --git a/server/tests/api/server/email.ts b/server/tests/api/server/email.ts
index 4ab5463fe..db7aa65bd 100644
--- a/server/tests/api/server/email.ts
+++ b/server/tests/api/server/email.ts
@@ -3,7 +3,14 @@
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { MockSmtpServer } from '@server/tests/shared' 4import { MockSmtpServer } from '@server/tests/shared'
5import { HttpStatusCode } from '@shared/models' 5import { HttpStatusCode } from '@shared/models'
6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands' 6import {
7 cleanupTests,
8 ConfigCommand,
9 createSingleServer,
10 PeerTubeServer,
11 setAccessTokensToServers,
12 waitJobs
13} from '@shared/server-commands'
7 14
8describe('Test emails', function () { 15describe('Test emails', function () {
9 let server: PeerTubeServer 16 let server: PeerTubeServer
@@ -24,21 +31,15 @@ describe('Test emails', function () {
24 username: 'user_1', 31 username: 'user_1',
25 password: 'super_password' 32 password: 'super_password'
26 } 33 }
27 let emailPort: number
28 34
29 before(async function () { 35 before(async function () {
30 this.timeout(50000) 36 this.timeout(50000)
31 37
32 emailPort = await MockSmtpServer.Instance.collectEmails(emails) 38 const emailPort = await MockSmtpServer.Instance.collectEmails(emails)
39 server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort))
33 40
34 const overrideConfig = {
35 smtp: {
36 hostname: '127.0.0.1',
37 port: emailPort
38 }
39 }
40 server = await createSingleServer(1, overrideConfig)
41 await setAccessTokensToServers([ server ]) 41 await setAccessTokensToServers([ server ])
42 await server.config.enableSignup(true)
42 43
43 { 44 {
44 const created = await server.users.create({ username: user.username, password: user.password }) 45 const created = await server.users.create({ username: user.username, password: user.password })
@@ -322,6 +323,62 @@ describe('Test emails', function () {
322 }) 323 })
323 }) 324 })
324 325
326 describe('When verifying a registration email', function () {
327 let registrationId: number
328 let registrationIdEmail: number
329
330 before(async function () {
331 const { id } = await server.registrations.requestRegistration({
332 username: 'request_1',
333 email: 'request_1@example.com',
334 registrationReason: 'tt'
335 })
336 registrationId = id
337 })
338
339 it('Should ask to send the verification email', async function () {
340 this.timeout(10000)
341
342 await server.registrations.askSendVerifyEmail({ email: 'request_1@example.com' })
343
344 await waitJobs(server)
345 expect(emails).to.have.lengthOf(9)
346
347 const email = emails[8]
348
349 expect(email['from'][0]['name']).equal('PeerTube')
350 expect(email['from'][0]['address']).equal('test-admin@127.0.0.1')
351 expect(email['to'][0]['address']).equal('request_1@example.com')
352 expect(email['subject']).contains('Verify')
353
354 const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
355 expect(verificationStringMatches).not.to.be.null
356
357 verificationString = verificationStringMatches[1]
358 expect(verificationString).to.not.be.undefined
359 expect(verificationString).to.have.length.above(2)
360
361 const registrationIdMatches = /registrationId=([0-9]+)/.exec(email['text'])
362 expect(registrationIdMatches).not.to.be.null
363
364 registrationIdEmail = parseInt(registrationIdMatches[1], 10)
365
366 expect(registrationId).to.equal(registrationIdEmail)
367 })
368
369 it('Should not verify the email with an invalid verification string', async function () {
370 await server.registrations.verifyEmail({
371 registrationId: registrationIdEmail,
372 verificationString: verificationString + 'b',
373 expectedStatus: HttpStatusCode.FORBIDDEN_403
374 })
375 })
376
377 it('Should verify the email', async function () {
378 await server.registrations.verifyEmail({ registrationId: registrationIdEmail, verificationString })
379 })
380 })
381
325 after(async function () { 382 after(async function () {
326 MockSmtpServer.Instance.kill() 383 MockSmtpServer.Instance.kill()
327 384
diff --git a/server/tests/api/server/reverse-proxy.ts b/server/tests/api/server/reverse-proxy.ts
index d882f0bde..11c96c4b5 100644
--- a/server/tests/api/server/reverse-proxy.ts
+++ b/server/tests/api/server/reverse-proxy.ts
@@ -106,13 +106,13 @@ describe('Test application behind a reverse proxy', function () {
106 it('Should rate limit signup', async function () { 106 it('Should rate limit signup', async function () {
107 for (let i = 0; i < 10; i++) { 107 for (let i = 0; i < 10; i++) {
108 try { 108 try {
109 await server.users.register({ username: 'test' + i }) 109 await server.registrations.register({ username: 'test' + i })
110 } catch { 110 } catch {
111 // empty 111 // empty
112 } 112 }
113 } 113 }
114 114
115 await server.users.register({ username: 'test42', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 }) 115 await server.registrations.register({ username: 'test42', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 })
116 }) 116 })
117 117
118 it('Should not rate limit failed signup', async function () { 118 it('Should not rate limit failed signup', async function () {
@@ -121,10 +121,10 @@ describe('Test application behind a reverse proxy', function () {
121 await wait(7000) 121 await wait(7000)
122 122
123 for (let i = 0; i < 3; i++) { 123 for (let i = 0; i < 3; i++) {
124 await server.users.register({ username: 'test' + i, expectedStatus: HttpStatusCode.CONFLICT_409 }) 124 await server.registrations.register({ username: 'test' + i, expectedStatus: HttpStatusCode.CONFLICT_409 })
125 } 125 }
126 126
127 await server.users.register({ username: 'test43', expectedStatus: HttpStatusCode.NO_CONTENT_204 }) 127 await server.registrations.register({ username: 'test43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
128 128
129 }) 129 })
130 130
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index 643f1a531..a4443a8ec 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,6 +1,8 @@
1import './oauth'
2import './registrations`'
1import './two-factor' 3import './two-factor'
2import './user-subscriptions' 4import './user-subscriptions'
3import './user-videos' 5import './user-videos'
4import './users' 6import './users'
5import './users-multiple-servers' 7import './users-multiple-servers'
6import './users-verification' 8import './users-email-verification'
diff --git a/server/tests/api/users/oauth.ts b/server/tests/api/users/oauth.ts
new file mode 100644
index 000000000..6a3da5ea2
--- /dev/null
+++ b/server/tests/api/users/oauth.ts
@@ -0,0 +1,192 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models'
6import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7
8describe('Test oauth', function () {
9 let server: PeerTubeServer
10
11 before(async function () {
12 this.timeout(30000)
13
14 server = await createSingleServer(1, {
15 rates_limit: {
16 login: {
17 max: 30
18 }
19 }
20 })
21
22 await setAccessTokensToServers([ server ])
23 })
24
25 describe('OAuth client', function () {
26
27 function expectInvalidClient (body: PeerTubeProblemDocument) {
28 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
29 expect(body.error).to.contain('client is invalid')
30 expect(body.type.startsWith('https://')).to.be.true
31 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
32 }
33
34 it('Should create a new client')
35
36 it('Should return the first client')
37
38 it('Should remove the last client')
39
40 it('Should not login with an invalid client id', async function () {
41 const client = { id: 'client', secret: server.store.client.secret }
42 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
43
44 expectInvalidClient(body)
45 })
46
47 it('Should not login with an invalid client secret', async function () {
48 const client = { id: server.store.client.id, secret: 'coucou' }
49 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
50
51 expectInvalidClient(body)
52 })
53 })
54
55 describe('Login', function () {
56
57 function expectInvalidCredentials (body: PeerTubeProblemDocument) {
58 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
59 expect(body.error).to.contain('credentials are invalid')
60 expect(body.type.startsWith('https://')).to.be.true
61 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
62 }
63
64 it('Should not login with an invalid username', async function () {
65 const user = { username: 'captain crochet', password: server.store.user.password }
66 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
67
68 expectInvalidCredentials(body)
69 })
70
71 it('Should not login with an invalid password', async function () {
72 const user = { username: server.store.user.username, password: 'mew_three' }
73 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
74
75 expectInvalidCredentials(body)
76 })
77
78 it('Should be able to login', async function () {
79 await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
80 })
81
82 it('Should be able to login with an insensitive username', async function () {
83 const user = { username: 'RoOt', password: server.store.user.password }
84 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
85
86 const user2 = { username: 'rOoT', password: server.store.user.password }
87 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
88
89 const user3 = { username: 'ROOt', password: server.store.user.password }
90 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
91 })
92 })
93
94 describe('Logout', function () {
95
96 it('Should logout (revoke token)', async function () {
97 await server.login.logout({ token: server.accessToken })
98 })
99
100 it('Should not be able to get the user information', async function () {
101 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
102 })
103
104 it('Should not be able to upload a video', async function () {
105 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
106 })
107
108 it('Should be able to login again', async function () {
109 const body = await server.login.login()
110 server.accessToken = body.access_token
111 server.refreshToken = body.refresh_token
112 })
113
114 it('Should be able to get my user information again', async function () {
115 await server.users.getMyInfo()
116 })
117
118 it('Should have an expired access token', async function () {
119 this.timeout(60000)
120
121 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
122 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
123
124 await killallServers([ server ])
125 await server.run()
126
127 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
128 })
129
130 it('Should not be able to refresh an access token with an expired refresh token', async function () {
131 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
132 })
133
134 it('Should refresh the token', async function () {
135 this.timeout(50000)
136
137 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
138 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
139
140 await killallServers([ server ])
141 await server.run()
142
143 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
144 server.accessToken = res.body.access_token
145 server.refreshToken = res.body.refresh_token
146 })
147
148 it('Should be able to get my user information again', async function () {
149 await server.users.getMyInfo()
150 })
151 })
152
153 describe('Custom token lifetime', function () {
154 before(async function () {
155 this.timeout(120_000)
156
157 await server.kill()
158 await server.run({
159 oauth2: {
160 token_lifetime: {
161 access_token: '2 seconds',
162 refresh_token: '2 seconds'
163 }
164 }
165 })
166 })
167
168 it('Should have a very short access token lifetime', async function () {
169 this.timeout(50000)
170
171 const { access_token: accessToken } = await server.login.login()
172 await server.users.getMyInfo({ token: accessToken })
173
174 await wait(3000)
175 await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
176 })
177
178 it('Should have a very short refresh token lifetime', async function () {
179 this.timeout(50000)
180
181 const { refresh_token: refreshToken } = await server.login.login()
182 await server.login.refreshToken({ refreshToken })
183
184 await wait(3000)
185 await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
186 })
187 })
188
189 after(async function () {
190 await cleanupTests([ server ])
191 })
192})
diff --git a/server/tests/api/users/registrations.ts b/server/tests/api/users/registrations.ts
new file mode 100644
index 000000000..e6524f07d
--- /dev/null
+++ b/server/tests/api/users/registrations.ts
@@ -0,0 +1,415 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { MockSmtpServer } from '@server/tests/shared'
5import { UserRegistrationState, UserRole } from '@shared/models'
6import {
7 cleanupTests,
8 ConfigCommand,
9 createSingleServer,
10 PeerTubeServer,
11 setAccessTokensToServers,
12 waitJobs
13} from '@shared/server-commands'
14
15describe('Test registrations', function () {
16 let server: PeerTubeServer
17
18 const emails: object[] = []
19 let emailPort: number
20
21 before(async function () {
22 this.timeout(30000)
23
24 emailPort = await MockSmtpServer.Instance.collectEmails(emails)
25
26 server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort))
27
28 await setAccessTokensToServers([ server ])
29 await server.config.enableSignup(false)
30 })
31
32 describe('Direct registrations of a new user', function () {
33 let user1Token: string
34
35 it('Should register a new user', async function () {
36 const user = { displayName: 'super user 1', username: 'user_1', password: 'my super password' }
37 const channel = { name: 'my_user_1_channel', displayName: 'my channel rocks' }
38
39 await server.registrations.register({ ...user, channel })
40 })
41
42 it('Should be able to login with this registered user', async function () {
43 const user1 = { username: 'user_1', password: 'my super password' }
44
45 user1Token = await server.login.getAccessToken(user1)
46 })
47
48 it('Should have the correct display name', async function () {
49 const user = await server.users.getMyInfo({ token: user1Token })
50 expect(user.account.displayName).to.equal('super user 1')
51 })
52
53 it('Should have the correct video quota', async function () {
54 const user = await server.users.getMyInfo({ token: user1Token })
55 expect(user.videoQuota).to.equal(5 * 1024 * 1024)
56 })
57
58 it('Should have created the channel', async function () {
59 const { displayName } = await server.channels.get({ channelName: 'my_user_1_channel' })
60
61 expect(displayName).to.equal('my channel rocks')
62 })
63
64 it('Should remove me', async function () {
65 {
66 const { data } = await server.users.list()
67 expect(data.find(u => u.username === 'user_1')).to.not.be.undefined
68 }
69
70 await server.users.deleteMe({ token: user1Token })
71
72 {
73 const { data } = await server.users.list()
74 expect(data.find(u => u.username === 'user_1')).to.be.undefined
75 }
76 })
77 })
78
79 describe('Registration requests', function () {
80 let id2: number
81 let id3: number
82 let id4: number
83
84 let user2Token: string
85 let user3Token: string
86
87 before(async function () {
88 this.timeout(60000)
89
90 await server.config.enableSignup(true)
91
92 {
93 const { id } = await server.registrations.requestRegistration({
94 username: 'user4',
95 registrationReason: 'registration reason 4'
96 })
97
98 id4 = id
99 }
100 })
101
102 it('Should request a registration without a channel', async function () {
103 {
104 const { id } = await server.registrations.requestRegistration({
105 username: 'user2',
106 displayName: 'my super user 2',
107 email: 'user2@example.com',
108 password: 'user2password',
109 registrationReason: 'registration reason 2'
110 })
111
112 id2 = id
113 }
114 })
115
116 it('Should request a registration with a channel', async function () {
117 const { id } = await server.registrations.requestRegistration({
118 username: 'user3',
119 displayName: 'my super user 3',
120 channel: {
121 displayName: 'my user 3 channel',
122 name: 'super_user3_channel'
123 },
124 email: 'user3@example.com',
125 password: 'user3password',
126 registrationReason: 'registration reason 3'
127 })
128
129 id3 = id
130 })
131
132 it('Should list these registration requests', async function () {
133 {
134 const { total, data } = await server.registrations.list({ sort: '-createdAt' })
135 expect(total).to.equal(3)
136 expect(data).to.have.lengthOf(3)
137
138 {
139 expect(data[0].id).to.equal(id3)
140 expect(data[0].username).to.equal('user3')
141 expect(data[0].accountDisplayName).to.equal('my super user 3')
142
143 expect(data[0].channelDisplayName).to.equal('my user 3 channel')
144 expect(data[0].channelHandle).to.equal('super_user3_channel')
145
146 expect(data[0].createdAt).to.exist
147 expect(data[0].updatedAt).to.exist
148
149 expect(data[0].email).to.equal('user3@example.com')
150 expect(data[0].emailVerified).to.be.null
151
152 expect(data[0].moderationResponse).to.be.null
153 expect(data[0].registrationReason).to.equal('registration reason 3')
154 expect(data[0].state.id).to.equal(UserRegistrationState.PENDING)
155 expect(data[0].state.label).to.equal('Pending')
156 expect(data[0].user).to.be.null
157 }
158
159 {
160 expect(data[1].id).to.equal(id2)
161 expect(data[1].username).to.equal('user2')
162 expect(data[1].accountDisplayName).to.equal('my super user 2')
163
164 expect(data[1].channelDisplayName).to.be.null
165 expect(data[1].channelHandle).to.be.null
166
167 expect(data[1].createdAt).to.exist
168 expect(data[1].updatedAt).to.exist
169
170 expect(data[1].email).to.equal('user2@example.com')
171 expect(data[1].emailVerified).to.be.null
172
173 expect(data[1].moderationResponse).to.be.null
174 expect(data[1].registrationReason).to.equal('registration reason 2')
175 expect(data[1].state.id).to.equal(UserRegistrationState.PENDING)
176 expect(data[1].state.label).to.equal('Pending')
177 expect(data[1].user).to.be.null
178 }
179
180 {
181 expect(data[2].username).to.equal('user4')
182 }
183 }
184
185 {
186 const { total, data } = await server.registrations.list({ count: 1, start: 1, sort: 'createdAt' })
187
188 expect(total).to.equal(3)
189 expect(data).to.have.lengthOf(1)
190 expect(data[0].id).to.equal(id2)
191 }
192
193 {
194 const { total, data } = await server.registrations.list({ search: 'user3' })
195 expect(total).to.equal(1)
196 expect(data).to.have.lengthOf(1)
197 expect(data[0].id).to.equal(id3)
198 }
199 })
200
201 it('Should reject a registration request', async function () {
202 await server.registrations.reject({ id: id4, moderationResponse: 'I do not want id 4 on this instance' })
203 })
204
205 it('Should have sent an email to the user explanining the registration has been rejected', async function () {
206 this.timeout(50000)
207
208 await waitJobs([ server ])
209
210 const email = emails.find(e => e['to'][0]['address'] === 'user4@example.com')
211 expect(email).to.exist
212
213 expect(email['subject']).to.contain('been rejected')
214 expect(email['text']).to.contain('been rejected')
215 expect(email['text']).to.contain('I do not want id 4 on this instance')
216 })
217
218 it('Should accept registration requests', async function () {
219 await server.registrations.accept({ id: id2, moderationResponse: 'Welcome id 2' })
220 await server.registrations.accept({ id: id3, moderationResponse: 'Welcome id 3' })
221 })
222
223 it('Should have sent an email to the user explanining the registration has been accepted', async function () {
224 this.timeout(50000)
225
226 await waitJobs([ server ])
227
228 {
229 const email = emails.find(e => e['to'][0]['address'] === 'user2@example.com')
230 expect(email).to.exist
231
232 expect(email['subject']).to.contain('been accepted')
233 expect(email['text']).to.contain('been accepted')
234 expect(email['text']).to.contain('Welcome id 2')
235 }
236
237 {
238 const email = emails.find(e => e['to'][0]['address'] === 'user3@example.com')
239 expect(email).to.exist
240
241 expect(email['subject']).to.contain('been accepted')
242 expect(email['text']).to.contain('been accepted')
243 expect(email['text']).to.contain('Welcome id 3')
244 }
245 })
246
247 it('Should login with these users', async function () {
248 user2Token = await server.login.getAccessToken({ username: 'user2', password: 'user2password' })
249 user3Token = await server.login.getAccessToken({ username: 'user3', password: 'user3password' })
250 })
251
252 it('Should have created the appropriate attributes for user 2', async function () {
253 const me = await server.users.getMyInfo({ token: user2Token })
254
255 expect(me.username).to.equal('user2')
256 expect(me.account.displayName).to.equal('my super user 2')
257 expect(me.videoQuota).to.equal(5 * 1024 * 1024)
258 expect(me.videoChannels[0].name).to.equal('user2_channel')
259 expect(me.videoChannels[0].displayName).to.equal('Main user2 channel')
260 expect(me.role.id).to.equal(UserRole.USER)
261 expect(me.email).to.equal('user2@example.com')
262 })
263
264 it('Should have created the appropriate attributes for user 3', async function () {
265 const me = await server.users.getMyInfo({ token: user3Token })
266
267 expect(me.username).to.equal('user3')
268 expect(me.account.displayName).to.equal('my super user 3')
269 expect(me.videoQuota).to.equal(5 * 1024 * 1024)
270 expect(me.videoChannels[0].name).to.equal('super_user3_channel')
271 expect(me.videoChannels[0].displayName).to.equal('my user 3 channel')
272 expect(me.role.id).to.equal(UserRole.USER)
273 expect(me.email).to.equal('user3@example.com')
274 })
275
276 it('Should list these accepted/rejected registration requests', async function () {
277 const { data } = await server.registrations.list({ sort: 'createdAt' })
278 const { data: users } = await server.users.list()
279
280 {
281 expect(data[0].id).to.equal(id4)
282 expect(data[0].state.id).to.equal(UserRegistrationState.REJECTED)
283 expect(data[0].state.label).to.equal('Rejected')
284
285 expect(data[0].moderationResponse).to.equal('I do not want id 4 on this instance')
286 expect(data[0].user).to.be.null
287
288 expect(users.find(u => u.username === 'user4')).to.not.exist
289 }
290
291 {
292 expect(data[1].id).to.equal(id2)
293 expect(data[1].state.id).to.equal(UserRegistrationState.ACCEPTED)
294 expect(data[1].state.label).to.equal('Accepted')
295
296 expect(data[1].moderationResponse).to.equal('Welcome id 2')
297 expect(data[1].user).to.exist
298
299 const user2 = users.find(u => u.username === 'user2')
300 expect(data[1].user.id).to.equal(user2.id)
301 }
302
303 {
304 expect(data[2].id).to.equal(id3)
305 expect(data[2].state.id).to.equal(UserRegistrationState.ACCEPTED)
306 expect(data[2].state.label).to.equal('Accepted')
307
308 expect(data[2].moderationResponse).to.equal('Welcome id 3')
309 expect(data[2].user).to.exist
310
311 const user3 = users.find(u => u.username === 'user3')
312 expect(data[2].user.id).to.equal(user3.id)
313 }
314 })
315
316 it('Shoulde delete a registration', async function () {
317 await server.registrations.delete({ id: id2 })
318 await server.registrations.delete({ id: id3 })
319
320 const { total, data } = await server.registrations.list()
321 expect(total).to.equal(1)
322 expect(data).to.have.lengthOf(1)
323 expect(data[0].id).to.equal(id4)
324
325 const { data: users } = await server.users.list()
326
327 for (const username of [ 'user2', 'user3' ]) {
328 expect(users.find(u => u.username === username)).to.exist
329 }
330 })
331
332 it('Should be able to prevent email delivery on accept/reject', async function () {
333 this.timeout(50000)
334
335 let id1: number
336 let id2: number
337
338 {
339 const { id } = await server.registrations.requestRegistration({
340 username: 'user7',
341 email: 'user7@example.com',
342 registrationReason: 'tt'
343 })
344 id1 = id
345 }
346 {
347 const { id } = await server.registrations.requestRegistration({
348 username: 'user8',
349 email: 'user8@example.com',
350 registrationReason: 'tt'
351 })
352 id2 = id
353 }
354
355 await server.registrations.accept({ id: id1, moderationResponse: 'tt', preventEmailDelivery: true })
356 await server.registrations.reject({ id: id2, moderationResponse: 'tt', preventEmailDelivery: true })
357
358 await waitJobs([ server ])
359
360 const filtered = emails.filter(e => {
361 const address = e['to'][0]['address']
362 return address === 'user7@example.com' || address === 'user8@example.com'
363 })
364
365 expect(filtered).to.have.lengthOf(0)
366 })
367
368 it('Should request a registration without a channel, that will conflict with an already existing channel', async function () {
369 let id1: number
370 let id2: number
371
372 {
373 const { id } = await server.registrations.requestRegistration({
374 registrationReason: 'tt',
375 username: 'user5',
376 password: 'user5password',
377 channel: {
378 displayName: 'channel 6',
379 name: 'user6_channel'
380 }
381 })
382
383 id1 = id
384 }
385
386 {
387 const { id } = await server.registrations.requestRegistration({
388 registrationReason: 'tt',
389 username: 'user6',
390 password: 'user6password'
391 })
392
393 id2 = id
394 }
395
396 await server.registrations.accept({ id: id1, moderationResponse: 'tt' })
397 await server.registrations.accept({ id: id2, moderationResponse: 'tt' })
398
399 const user5Token = await server.login.getAccessToken('user5', 'user5password')
400 const user6Token = await server.login.getAccessToken('user6', 'user6password')
401
402 const user5 = await server.users.getMyInfo({ token: user5Token })
403 const user6 = await server.users.getMyInfo({ token: user6Token })
404
405 expect(user5.videoChannels[0].name).to.equal('user6_channel')
406 expect(user6.videoChannels[0].name).to.equal('user6_channel-1')
407 })
408 })
409
410 after(async function () {
411 MockSmtpServer.Instance.kill()
412
413 await cleanupTests([ server ])
414 })
415})
diff --git a/server/tests/api/users/users-verification.ts b/server/tests/api/users/users-email-verification.ts
index 19a8df9e1..cb84dc758 100644
--- a/server/tests/api/users/users-verification.ts
+++ b/server/tests/api/users/users-email-verification.ts
@@ -3,9 +3,16 @@
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { MockSmtpServer } from '@server/tests/shared' 4import { MockSmtpServer } from '@server/tests/shared'
5import { HttpStatusCode } from '@shared/models' 5import { HttpStatusCode } from '@shared/models'
6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands' 6import {
7 7 cleanupTests,
8describe('Test users account verification', function () { 8 ConfigCommand,
9 createSingleServer,
10 PeerTubeServer,
11 setAccessTokensToServers,
12 waitJobs
13} from '@shared/server-commands'
14
15describe('Test users email verification', function () {
9 let server: PeerTubeServer 16 let server: PeerTubeServer
10 let userId: number 17 let userId: number
11 let userAccessToken: string 18 let userAccessToken: string
@@ -25,14 +32,7 @@ describe('Test users account verification', function () {
25 this.timeout(30000) 32 this.timeout(30000)
26 33
27 const port = await MockSmtpServer.Instance.collectEmails(emails) 34 const port = await MockSmtpServer.Instance.collectEmails(emails)
28 35 server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port))
29 const overrideConfig = {
30 smtp: {
31 hostname: '127.0.0.1',
32 port
33 }
34 }
35 server = await createSingleServer(1, overrideConfig)
36 36
37 await setAccessTokensToServers([ server ]) 37 await setAccessTokensToServers([ server ])
38 }) 38 })
@@ -40,17 +40,18 @@ describe('Test users account verification', function () {
40 it('Should register user and send verification email if verification required', async function () { 40 it('Should register user and send verification email if verification required', async function () {
41 this.timeout(30000) 41 this.timeout(30000)
42 42
43 await server.config.updateCustomSubConfig({ 43 await server.config.updateExistingSubConfig({
44 newConfig: { 44 newConfig: {
45 signup: { 45 signup: {
46 enabled: true, 46 enabled: true,
47 requiresApproval: false,
47 requiresEmailVerification: true, 48 requiresEmailVerification: true,
48 limit: 10 49 limit: 10
49 } 50 }
50 } 51 }
51 }) 52 })
52 53
53 await server.users.register(user1) 54 await server.registrations.register(user1)
54 55
55 await waitJobs(server) 56 await waitJobs(server)
56 expectedEmailsLength++ 57 expectedEmailsLength++
@@ -127,17 +128,15 @@ describe('Test users account verification', function () {
127 128
128 it('Should register user not requiring email verification if setting not enabled', async function () { 129 it('Should register user not requiring email verification if setting not enabled', async function () {
129 this.timeout(5000) 130 this.timeout(5000)
130 await server.config.updateCustomSubConfig({ 131 await server.config.updateExistingSubConfig({
131 newConfig: { 132 newConfig: {
132 signup: { 133 signup: {
133 enabled: true, 134 requiresEmailVerification: false
134 requiresEmailVerification: false,
135 limit: 10
136 } 135 }
137 } 136 }
138 }) 137 })
139 138
140 await server.users.register(user2) 139 await server.registrations.register(user2)
141 140
142 await waitJobs(server) 141 await waitJobs(server)
143 expect(emails).to.have.lengthOf(expectedEmailsLength) 142 expect(emails).to.have.lengthOf(expectedEmailsLength)
@@ -152,9 +151,7 @@ describe('Test users account verification', function () {
152 await server.config.updateCustomSubConfig({ 151 await server.config.updateCustomSubConfig({
153 newConfig: { 152 newConfig: {
154 signup: { 153 signup: {
155 enabled: true, 154 requiresEmailVerification: true
156 requiresEmailVerification: true,
157 limit: 10
158 } 155 }
159 } 156 }
160 }) 157 })
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 421b3ce16..f1e170971 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -2,15 +2,8 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { testImage } from '@server/tests/shared' 4import { testImage } from '@server/tests/shared'
5import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' 5import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
6import { 6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7 cleanupTests,
8 createSingleServer,
9 killallServers,
10 makePutBodyRequest,
11 PeerTubeServer,
12 setAccessTokensToServers
13} from '@shared/server-commands'
14 7
15describe('Test users', function () { 8describe('Test users', function () {
16 let server: PeerTubeServer 9 let server: PeerTubeServer
@@ -39,166 +32,6 @@ describe('Test users', function () {
39 await server.plugins.install({ npmName: 'peertube-theme-background-red' }) 32 await server.plugins.install({ npmName: 'peertube-theme-background-red' })
40 }) 33 })
41 34
42 describe('OAuth client', function () {
43 it('Should create a new client')
44
45 it('Should return the first client')
46
47 it('Should remove the last client')
48
49 it('Should not login with an invalid client id', async function () {
50 const client = { id: 'client', secret: server.store.client.secret }
51 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
52
53 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
54 expect(body.error).to.contain('client is invalid')
55 expect(body.type.startsWith('https://')).to.be.true
56 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
57 })
58
59 it('Should not login with an invalid client secret', async function () {
60 const client = { id: server.store.client.id, secret: 'coucou' }
61 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
62
63 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
64 expect(body.error).to.contain('client is invalid')
65 expect(body.type.startsWith('https://')).to.be.true
66 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
67 })
68 })
69
70 describe('Login', function () {
71
72 it('Should not login with an invalid username', async function () {
73 const user = { username: 'captain crochet', password: server.store.user.password }
74 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
75
76 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
77 expect(body.error).to.contain('credentials are invalid')
78 expect(body.type.startsWith('https://')).to.be.true
79 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
80 })
81
82 it('Should not login with an invalid password', async function () {
83 const user = { username: server.store.user.username, password: 'mew_three' }
84 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
85
86 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
87 expect(body.error).to.contain('credentials are invalid')
88 expect(body.type.startsWith('https://')).to.be.true
89 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
90 })
91
92 it('Should not be able to upload a video', async function () {
93 token = 'my_super_token'
94
95 await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
96 })
97
98 it('Should not be able to follow', async function () {
99 token = 'my_super_token'
100
101 await server.follows.follow({
102 hosts: [ 'http://example.com' ],
103 token,
104 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
105 })
106 })
107
108 it('Should not be able to unfollow')
109
110 it('Should be able to login', async function () {
111 const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
112
113 token = body.access_token
114 })
115
116 it('Should be able to login with an insensitive username', async function () {
117 const user = { username: 'RoOt', password: server.store.user.password }
118 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
119
120 const user2 = { username: 'rOoT', password: server.store.user.password }
121 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
122
123 const user3 = { username: 'ROOt', password: server.store.user.password }
124 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
125 })
126 })
127
128 describe('Logout', function () {
129 it('Should logout (revoke token)', async function () {
130 await server.login.logout({ token: server.accessToken })
131 })
132
133 it('Should not be able to get the user information', async function () {
134 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
135 })
136
137 it('Should not be able to upload a video', async function () {
138 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
139 })
140
141 it('Should not be able to rate a video', async function () {
142 const path = '/api/v1/videos/'
143 const data = {
144 rating: 'likes'
145 }
146
147 const options = {
148 url: server.url,
149 path: path + videoId,
150 token: 'wrong token',
151 fields: data,
152 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
153 }
154 await makePutBodyRequest(options)
155 })
156
157 it('Should be able to login again', async function () {
158 const body = await server.login.login()
159 server.accessToken = body.access_token
160 server.refreshToken = body.refresh_token
161 })
162
163 it('Should be able to get my user information again', async function () {
164 await server.users.getMyInfo()
165 })
166
167 it('Should have an expired access token', async function () {
168 this.timeout(60000)
169
170 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
171 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
172
173 await killallServers([ server ])
174 await server.run()
175
176 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
177 })
178
179 it('Should not be able to refresh an access token with an expired refresh token', async function () {
180 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
181 })
182
183 it('Should refresh the token', async function () {
184 this.timeout(50000)
185
186 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
187 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
188
189 await killallServers([ server ])
190 await server.run()
191
192 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
193 server.accessToken = res.body.access_token
194 server.refreshToken = res.body.refresh_token
195 })
196
197 it('Should be able to get my user information again', async function () {
198 await server.users.getMyInfo()
199 })
200 })
201
202 describe('Creating a user', function () { 35 describe('Creating a user', function () {
203 36
204 it('Should be able to create a new user', async function () { 37 it('Should be able to create a new user', async function () {
@@ -512,6 +345,7 @@ describe('Test users', function () {
512 }) 345 })
513 346
514 describe('Updating another user', function () { 347 describe('Updating another user', function () {
348
515 it('Should be able to update another user', async function () { 349 it('Should be able to update another user', async function () {
516 await server.users.update({ 350 await server.users.update({
517 userId, 351 userId,
@@ -562,13 +396,6 @@ describe('Test users', function () {
562 }) 396 })
563 }) 397 })
564 398
565 describe('Video blacklists', function () {
566
567 it('Should be able to list my video blacklist', async function () {
568 await server.blacklist.list({ token: userToken })
569 })
570 })
571
572 describe('Remove a user', function () { 399 describe('Remove a user', function () {
573 400
574 before(async function () { 401 before(async function () {
@@ -602,59 +429,10 @@ describe('Test users', function () {
602 }) 429 })
603 }) 430 })
604 431
605 describe('Registering a new user', function () {
606 let user15AccessToken: string
607
608 it('Should register a new user', async function () {
609 const user = { displayName: 'super user 15', username: 'user_15', password: 'my super password' }
610 const channel = { name: 'my_user_15_channel', displayName: 'my channel rocks' }
611
612 await server.users.register({ ...user, channel })
613 })
614
615 it('Should be able to login with this registered user', async function () {
616 const user15 = {
617 username: 'user_15',
618 password: 'my super password'
619 }
620
621 user15AccessToken = await server.login.getAccessToken(user15)
622 })
623
624 it('Should have the correct display name', async function () {
625 const user = await server.users.getMyInfo({ token: user15AccessToken })
626 expect(user.account.displayName).to.equal('super user 15')
627 })
628
629 it('Should have the correct video quota', async function () {
630 const user = await server.users.getMyInfo({ token: user15AccessToken })
631 expect(user.videoQuota).to.equal(5 * 1024 * 1024)
632 })
633
634 it('Should have created the channel', async function () {
635 const { displayName } = await server.channels.get({ channelName: 'my_user_15_channel' })
636
637 expect(displayName).to.equal('my channel rocks')
638 })
639
640 it('Should remove me', async function () {
641 {
642 const { data } = await server.users.list()
643 expect(data.find(u => u.username === 'user_15')).to.not.be.undefined
644 }
645
646 await server.users.deleteMe({ token: user15AccessToken })
647
648 {
649 const { data } = await server.users.list()
650 expect(data.find(u => u.username === 'user_15')).to.be.undefined
651 }
652 })
653 })
654
655 describe('User blocking', function () { 432 describe('User blocking', function () {
656 let user16Id 433 let user16Id: number
657 let user16AccessToken 434 let user16AccessToken: string
435
658 const user16 = { 436 const user16 = {
659 username: 'user_16', 437 username: 'user_16',
660 password: 'my super password' 438 password: 'my super password'
diff --git a/server/tests/api/videos/video-channel-syncs.ts b/server/tests/api/videos/video-channel-syncs.ts
index 91291524d..dd483f95e 100644
--- a/server/tests/api/videos/video-channel-syncs.ts
+++ b/server/tests/api/videos/video-channel-syncs.ts
@@ -307,6 +307,7 @@ describe('Test channel synchronizations', function () {
307 }) 307 })
308 } 308 }
309 309
310 runSuite('youtube-dl') 310 // FIXME: suite is broken with youtube-dl
311 // runSuite('youtube-dl')
311 runSuite('yt-dlp') 312 runSuite('yt-dlp')
312}) 313})
diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts
index dc47f8a4a..e35500b0b 100644
--- a/server/tests/api/videos/video-comments.ts
+++ b/server/tests/api/videos/video-comments.ts
@@ -38,6 +38,8 @@ describe('Test video comments', function () {
38 await setDefaultAccountAvatar(server) 38 await setDefaultAccountAvatar(server)
39 39
40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1') 40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
41 await setDefaultChannelAvatar(server, 'user1_channel')
42 await setDefaultAccountAvatar(server, userAccessTokenServer1)
41 43
42 command = server.comments 44 command = server.comments
43 }) 45 })
@@ -167,6 +169,13 @@ describe('Test video comments', function () {
167 expect(body.data[2].totalReplies).to.equal(0) 169 expect(body.data[2].totalReplies).to.equal(0)
168 }) 170 })
169 171
172 it('Should list the and sort them by total replies', async function () {
173 const body = await command.listThreads({ videoId: videoUUID, sort: 'totalReplies' })
174
175 expect(body.data[2].text).to.equal('my super first comment')
176 expect(body.data[2].totalReplies).to.equal(3)
177 })
178
170 it('Should delete a reply', async function () { 179 it('Should delete a reply', async function () {
171 await command.delete({ videoId, commentId: replyToDeleteId }) 180 await command.delete({ videoId, commentId: replyToDeleteId })
172 181
@@ -232,16 +241,34 @@ describe('Test video comments', function () {
232 await command.addReply({ videoId, toCommentId: threadId2, text: text3 }) 241 await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
233 242
234 const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 }) 243 const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
235 expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1) 244 expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1)
245 expect(tree.comment.totalReplies).to.equal(2)
236 }) 246 })
237 }) 247 })
238 248
239 describe('All instance comments', function () { 249 describe('All instance comments', function () {
240 250
241 it('Should list instance comments as admin', async function () { 251 it('Should list instance comments as admin', async function () {
242 const { data } = await command.listForAdmin({ start: 0, count: 1 }) 252 {
253 const { data, total } = await command.listForAdmin({ start: 0, count: 1 })
254
255 expect(total).to.equal(7)
256 expect(data).to.have.lengthOf(1)
257 expect(data[0].text).to.equal('my second answer to thread 4')
258 expect(data[0].account.name).to.equal('root')
259 expect(data[0].account.displayName).to.equal('root')
260 expect(data[0].account.avatars).to.have.lengthOf(2)
261 }
262
263 {
264 const { data, total } = await command.listForAdmin({ start: 1, count: 2 })
243 265
244 expect(data[0].text).to.equal('my second answer to thread 4') 266 expect(total).to.equal(7)
267 expect(data).to.have.lengthOf(2)
268
269 expect(data[0].account.avatars).to.have.lengthOf(2)
270 expect(data[1].account.avatars).to.have.lengthOf(2)
271 }
245 }) 272 })
246 273
247 it('Should filter instance comments by isLocal', async function () { 274 it('Should filter instance comments by isLocal', async function () {
diff --git a/server/tests/api/videos/video-imports.ts b/server/tests/api/videos/video-imports.ts
index 0583134b2..5636de45f 100644
--- a/server/tests/api/videos/video-imports.ts
+++ b/server/tests/api/videos/video-imports.ts
@@ -41,7 +41,7 @@ async function checkVideosServer1 (server: PeerTubeServer, idHttp: string, idMag
41 const videoTorrent = await server.videos.get({ id: idTorrent }) 41 const videoTorrent = await server.videos.get({ id: idTorrent })
42 42
43 for (const video of [ videoMagnet, videoTorrent ]) { 43 for (const video of [ videoMagnet, videoTorrent ]) {
44 expect(video.category.label).to.equal('Misc') 44 expect(video.category.label).to.equal('Unknown')
45 expect(video.licence.label).to.equal('Unknown') 45 expect(video.licence.label).to.equal('Unknown')
46 expect(video.language.label).to.equal('Unknown') 46 expect(video.language.label).to.equal('Unknown')
47 expect(video.nsfw).to.be.false 47 expect(video.nsfw).to.be.false
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index 6a18cf26a..e8e653382 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -3,6 +3,7 @@
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { checkPlaylistFilesWereRemoved, testImage } from '@server/tests/shared' 4import { checkPlaylistFilesWereRemoved, testImage } from '@server/tests/shared'
5import { wait } from '@shared/core-utils' 5import { wait } from '@shared/core-utils'
6import { uuidToShort } from '@shared/extra-utils'
6import { 7import {
7 HttpStatusCode, 8 HttpStatusCode,
8 VideoPlaylist, 9 VideoPlaylist,
@@ -23,7 +24,6 @@ import {
23 setDefaultVideoChannel, 24 setDefaultVideoChannel,
24 waitJobs 25 waitJobs
25} from '@shared/server-commands' 26} from '@shared/server-commands'
26import { uuidToShort } from '@shared/extra-utils'
27 27
28async function checkPlaylistElementType ( 28async function checkPlaylistElementType (
29 servers: PeerTubeServer[], 29 servers: PeerTubeServer[],
@@ -752,19 +752,6 @@ describe('Test video playlists', function () {
752 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) 752 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
753 } 753 }
754 }) 754 })
755
756 it('Should hide the video if it is NSFW', async function () {
757 const body = await commands[0].listVideos({ token: userTokenServer1, playlistId: playlistServer1UUID2, query: { nsfw: 'false' } })
758 expect(body.total).to.equal(3)
759
760 const elements = body.data
761 const element = elements.find(e => e.position === 3)
762
763 expect(element).to.exist
764 expect(element.video).to.be.null
765 expect(element.type).to.equal(VideoPlaylistElementType.UNAVAILABLE)
766 })
767
768 }) 755 })
769 756
770 describe('Managing playlist elements', function () { 757 describe('Managing playlist elements', function () {
diff --git a/server/tests/external-plugins/akismet.ts b/server/tests/external-plugins/akismet.ts
index 974bf0011..e964bf0c2 100644
--- a/server/tests/external-plugins/akismet.ts
+++ b/server/tests/external-plugins/akismet.ts
@@ -138,14 +138,14 @@ describe('Official plugin Akismet', function () {
138 }) 138 })
139 139
140 it('Should allow signup', async function () { 140 it('Should allow signup', async function () {
141 await servers[0].users.register({ 141 await servers[0].registrations.register({
142 username: 'user1', 142 username: 'user1',
143 displayName: 'user 1' 143 displayName: 'user 1'
144 }) 144 })
145 }) 145 })
146 146
147 it('Should detect a signup as SPAM', async function () { 147 it('Should detect a signup as SPAM', async function () {
148 await servers[0].users.register({ 148 await servers[0].registrations.register({
149 username: 'user2', 149 username: 'user2',
150 displayName: 'user 2', 150 displayName: 'user 2',
151 email: 'akismet-guaranteed-spam@example.com', 151 email: 'akismet-guaranteed-spam@example.com',
diff --git a/server/tests/external-plugins/auto-block-videos.ts b/server/tests/external-plugins/auto-block-videos.ts
index d14587c38..cadd02e8d 100644
--- a/server/tests/external-plugins/auto-block-videos.ts
+++ b/server/tests/external-plugins/auto-block-videos.ts
@@ -30,7 +30,7 @@ describe('Official plugin auto-block videos', function () {
30 let port: number 30 let port: number
31 31
32 before(async function () { 32 before(async function () {
33 this.timeout(60000) 33 this.timeout(120000)
34 34
35 servers = await createMultipleServers(2) 35 servers = await createMultipleServers(2)
36 await setAccessTokensToServers(servers) 36 await setAccessTokensToServers(servers)
diff --git a/server/tests/external-plugins/auto-mute.ts b/server/tests/external-plugins/auto-mute.ts
index 440b58bfd..cfed76e88 100644
--- a/server/tests/external-plugins/auto-mute.ts
+++ b/server/tests/external-plugins/auto-mute.ts
@@ -21,7 +21,7 @@ describe('Official plugin auto-mute', function () {
21 let port: number 21 let port: number
22 22
23 before(async function () { 23 before(async function () {
24 this.timeout(30000) 24 this.timeout(120000)
25 25
26 servers = await createMultipleServers(2) 26 servers = await createMultipleServers(2)
27 await setAccessTokensToServers(servers) 27 await setAccessTokensToServers(servers)
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 906dab1a3..7345f728a 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -189,7 +189,7 @@ describe('Test syndication feeds', () => {
189 const jsonObj = JSON.parse(json) 189 const jsonObj = JSON.parse(json)
190 expect(jsonObj.items.length).to.be.equal(1) 190 expect(jsonObj.items.length).to.be.equal(1)
191 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 191 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
192 expect(jsonObj.items[0].author.name).to.equal('root') 192 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
193 } 193 }
194 194
195 { 195 {
@@ -197,7 +197,7 @@ describe('Test syndication feeds', () => {
197 const jsonObj = JSON.parse(json) 197 const jsonObj = JSON.parse(json)
198 expect(jsonObj.items.length).to.be.equal(1) 198 expect(jsonObj.items.length).to.be.equal(1)
199 expect(jsonObj.items[0].title).to.equal('user video') 199 expect(jsonObj.items[0].title).to.equal('user video')
200 expect(jsonObj.items[0].author.name).to.equal('john') 200 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
201 } 201 }
202 202
203 for (const server of servers) { 203 for (const server of servers) {
@@ -223,7 +223,7 @@ describe('Test syndication feeds', () => {
223 const jsonObj = JSON.parse(json) 223 const jsonObj = JSON.parse(json)
224 expect(jsonObj.items.length).to.be.equal(1) 224 expect(jsonObj.items.length).to.be.equal(1)
225 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 225 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
226 expect(jsonObj.items[0].author.name).to.equal('root') 226 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
227 } 227 }
228 228
229 { 229 {
@@ -231,7 +231,7 @@ describe('Test syndication feeds', () => {
231 const jsonObj = JSON.parse(json) 231 const jsonObj = JSON.parse(json)
232 expect(jsonObj.items.length).to.be.equal(1) 232 expect(jsonObj.items.length).to.be.equal(1)
233 expect(jsonObj.items[0].title).to.equal('user video') 233 expect(jsonObj.items[0].title).to.equal('user video')
234 expect(jsonObj.items[0].author.name).to.equal('john') 234 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
235 } 235 }
236 236
237 for (const server of servers) { 237 for (const server of servers) {
diff --git a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
index c65b8d3a8..58bc27661 100644
--- a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
@@ -33,7 +33,17 @@ async function register ({
33 username: 'kefka', 33 username: 'kefka',
34 email: 'kefka@example.com', 34 email: 'kefka@example.com',
35 role: 0, 35 role: 0,
36 displayName: 'Kefka Palazzo' 36 displayName: 'Kefka Palazzo',
37 adminFlags: 1,
38 videoQuota: 42000,
39 videoQuotaDaily: 42100,
40
41 // Always use new value except for videoQuotaDaily field
42 userUpdater: ({ fieldName, currentValue, newValue }) => {
43 if (fieldName === 'videoQuotaDaily') return currentValue
44
45 return newValue
46 }
37 }) 47 })
38 }, 48 },
39 hookTokenValidity: (options) => { 49 hookTokenValidity: (options) => {
diff --git a/server/tests/fixtures/peertube-plugin-test-four/main.js b/server/tests/fixtures/peertube-plugin-test-four/main.js
index 3e848c49e..b10177b45 100644
--- a/server/tests/fixtures/peertube-plugin-test-four/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-four/main.js
@@ -76,6 +76,12 @@ async function register ({
76 return res.json({ serverConfig }) 76 return res.json({ serverConfig })
77 }) 77 })
78 78
79 router.get('/server-listening-config', async (req, res) => {
80 const config = await peertubeHelpers.config.getServerListeningConfig()
81
82 return res.json({ config })
83 })
84
79 router.get('/static-route', async (req, res) => { 85 router.get('/static-route', async (req, res) => {
80 const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute() 86 const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute()
81 87
diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
index ceab7b60d..fad5abf60 100644
--- a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
@@ -33,7 +33,18 @@ async function register ({
33 if (body.id === 'laguna' && body.password === 'laguna password') { 33 if (body.id === 'laguna' && body.password === 'laguna password') {
34 return Promise.resolve({ 34 return Promise.resolve({
35 username: 'laguna', 35 username: 'laguna',
36 email: 'laguna@example.com' 36 email: 'laguna@example.com',
37 displayName: 'Laguna Loire',
38 adminFlags: 1,
39 videoQuota: 42000,
40 videoQuotaDaily: 42100,
41
42 // Always use new value except for videoQuotaDaily field
43 userUpdater: ({ fieldName, currentValue, newValue }) => {
44 if (fieldName === 'videoQuotaDaily') return currentValue
45
46 return newValue
47 }
37 }) 48 })
38 } 49 }
39 50
diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index 19dccf26e..5b4d34f15 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -226,16 +226,29 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
226 } 226 }
227 }) 227 })
228 228
229 registerHook({ 229 {
230 target: 'filter:api.user.signup.allowed.result', 230 registerHook({
231 handler: (result, params) => { 231 target: 'filter:api.user.signup.allowed.result',
232 if (params && params.body && params.body.email && params.body.email.includes('jma')) { 232 handler: (result, params) => {
233 return { allowed: false, errorMessage: 'No jma' } 233 if (params && params.body && params.body.email && params.body.email.includes('jma 1')) {
234 return { allowed: false, errorMessage: 'No jma 1' }
235 }
236
237 return result
234 } 238 }
239 })
235 240
236 return result 241 registerHook({
237 } 242 target: 'filter:api.user.request-signup.allowed.result',
238 }) 243 handler: (result, params) => {
244 if (params && params.body && params.body.email && params.body.email.includes('jma 2')) {
245 return { allowed: false, errorMessage: 'No jma 2' }
246 }
247
248 return result
249 }
250 })
251 }
239 252
240 registerHook({ 253 registerHook({
241 target: 'filter:api.download.torrent.allowed.result', 254 target: 'filter:api.download.torrent.allowed.result',
@@ -250,7 +263,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
250 263
251 registerHook({ 264 registerHook({
252 target: 'filter:api.download.video.allowed.result', 265 target: 'filter:api.download.video.allowed.result',
253 handler: (result, params) => { 266 handler: async (result, params) => {
267 const loggedInUser = await peertubeHelpers.user.getAuthUser(params.res)
268 if (loggedInUser) return { allowed: true }
269
254 if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) { 270 if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
255 return { allowed: false, errorMessage: 'Cao Cao' } 271 return { allowed: false, errorMessage: 'Cao Cao' }
256 } 272 }
diff --git a/server/tests/helpers/index.ts b/server/tests/helpers/index.ts
index 1b5c6d15b..073ae6455 100644
--- a/server/tests/helpers/index.ts
+++ b/server/tests/helpers/index.ts
@@ -6,3 +6,4 @@ import './image'
6import './markdown' 6import './markdown'
7import './request' 7import './request'
8import './validator' 8import './validator'
9import './version'
diff --git a/server/tests/helpers/version.ts b/server/tests/helpers/version.ts
new file mode 100644
index 000000000..2a90efba3
--- /dev/null
+++ b/server/tests/helpers/version.ts
@@ -0,0 +1,36 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { compareSemVer } from '@shared/core-utils'
5
6describe('Version', function () {
7
8 it('Should correctly compare two stable versions', async function () {
9 expect(compareSemVer('3.4.0', '3.5.0')).to.be.below(0)
10 expect(compareSemVer('3.5.0', '3.4.0')).to.be.above(0)
11
12 expect(compareSemVer('3.4.0', '4.1.0')).to.be.below(0)
13 expect(compareSemVer('4.1.0', '3.4.0')).to.be.above(0)
14
15 expect(compareSemVer('3.4.0', '3.4.1')).to.be.below(0)
16 expect(compareSemVer('3.4.1', '3.4.0')).to.be.above(0)
17 })
18
19 it('Should correctly compare two unstable version', async function () {
20 expect(compareSemVer('3.4.0-alpha', '3.4.0-beta.1')).to.be.below(0)
21 expect(compareSemVer('3.4.0-alpha.1', '3.4.0-beta.1')).to.be.below(0)
22 expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0)
23 expect(compareSemVer('3.4.0-beta.1', '3.5.0-alpha.1')).to.be.below(0)
24
25 expect(compareSemVer('3.4.0-alpha.1', '3.4.0-nightly.4')).to.be.below(0)
26 expect(compareSemVer('3.4.0-nightly.3', '3.4.0-nightly.4')).to.be.below(0)
27 expect(compareSemVer('3.3.0-nightly.5', '3.4.0-nightly.4')).to.be.below(0)
28 })
29
30 it('Should correctly compare a stable and unstable versions', async function () {
31 expect(compareSemVer('3.4.0', '3.4.1-beta.1')).to.be.below(0)
32 expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0)
33 expect(compareSemVer('3.4.0-beta.1', '3.4.0')).to.be.below(0)
34 expect(compareSemVer('3.4.0-nightly.4', '3.4.0')).to.be.below(0)
35 })
36})
diff --git a/server/tests/plugins/action-hooks.ts b/server/tests/plugins/action-hooks.ts
index 36f8052c0..a266ae7f1 100644
--- a/server/tests/plugins/action-hooks.ts
+++ b/server/tests/plugins/action-hooks.ts
@@ -153,7 +153,7 @@ describe('Test plugin action hooks', function () {
153 let userId: number 153 let userId: number
154 154
155 it('Should run action:api.user.registered', async function () { 155 it('Should run action:api.user.registered', async function () {
156 await servers[0].users.register({ username: 'registered_user' }) 156 await servers[0].registrations.register({ username: 'registered_user' })
157 157
158 await checkHook('action:api.user.registered') 158 await checkHook('action:api.user.registered')
159 }) 159 })
diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts
index 437777e90..e600f958f 100644
--- a/server/tests/plugins/external-auth.ts
+++ b/server/tests/plugins/external-auth.ts
@@ -2,7 +2,7 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { wait } from '@shared/core-utils' 4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, UserRole } from '@shared/models' 5import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 createSingleServer, 8 createSingleServer,
@@ -51,6 +51,7 @@ describe('Test external auth plugins', function () {
51 51
52 let kefkaAccessToken: string 52 let kefkaAccessToken: string
53 let kefkaRefreshToken: string 53 let kefkaRefreshToken: string
54 let kefkaId: number
54 55
55 let externalAuthToken: string 56 let externalAuthToken: string
56 57
@@ -156,6 +157,9 @@ describe('Test external auth plugins', function () {
156 expect(body.account.displayName).to.equal('cyan') 157 expect(body.account.displayName).to.equal('cyan')
157 expect(body.email).to.equal('cyan@example.com') 158 expect(body.email).to.equal('cyan@example.com')
158 expect(body.role.id).to.equal(UserRole.USER) 159 expect(body.role.id).to.equal(UserRole.USER)
160 expect(body.adminFlags).to.equal(UserAdminFlag.NONE)
161 expect(body.videoQuota).to.equal(5242880)
162 expect(body.videoQuotaDaily).to.equal(-1)
159 } 163 }
160 }) 164 })
161 165
@@ -178,6 +182,11 @@ describe('Test external auth plugins', function () {
178 expect(body.account.displayName).to.equal('Kefka Palazzo') 182 expect(body.account.displayName).to.equal('Kefka Palazzo')
179 expect(body.email).to.equal('kefka@example.com') 183 expect(body.email).to.equal('kefka@example.com')
180 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) 184 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR)
185 expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)
186 expect(body.videoQuota).to.equal(42000)
187 expect(body.videoQuotaDaily).to.equal(42100)
188
189 kefkaId = body.id
181 } 190 }
182 }) 191 })
183 192
@@ -240,6 +249,37 @@ describe('Test external auth plugins', function () {
240 expect(body.role.id).to.equal(UserRole.USER) 249 expect(body.role.id).to.equal(UserRole.USER)
241 }) 250 })
242 251
252 it('Should login Kefka and update the profile', async function () {
253 {
254 await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 })
255 await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' })
256
257 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
258 expect(body.username).to.equal('kefka')
259 expect(body.account.displayName).to.equal('kefka updated')
260 expect(body.videoQuota).to.equal(43000)
261 expect(body.videoQuotaDaily).to.equal(43100)
262 }
263
264 {
265 const res = await loginExternal({
266 server,
267 npmName: 'test-external-auth-one',
268 authName: 'external-auth-2',
269 username: 'kefka'
270 })
271
272 kefkaAccessToken = res.access_token
273 kefkaRefreshToken = res.refresh_token
274
275 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
276 expect(body.username).to.equal('kefka')
277 expect(body.account.displayName).to.equal('Kefka Palazzo')
278 expect(body.videoQuota).to.equal(42000)
279 expect(body.videoQuotaDaily).to.equal(43100)
280 }
281 })
282
243 it('Should not update an external auth email', async function () { 283 it('Should not update an external auth email', async function () {
244 await server.users.updateMe({ 284 await server.users.updateMe({
245 token: cyanAccessToken, 285 token: cyanAccessToken,
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index 083fd43ca..37eef6cf3 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -1,7 +1,15 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { HttpStatusCode, VideoDetails, VideoImportState, VideoPlaylist, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models' 4import {
5 HttpStatusCode,
6 PeerTubeProblemDocument,
7 VideoDetails,
8 VideoImportState,
9 VideoPlaylist,
10 VideoPlaylistPrivacy,
11 VideoPrivacy
12} from '@shared/models'
5import { 13import {
6 cleanupTests, 14 cleanupTests,
7 createMultipleServers, 15 createMultipleServers,
@@ -408,28 +416,58 @@ describe('Test plugin filter hooks', function () {
408 416
409 describe('Should run filter:api.user.signup.allowed.result', function () { 417 describe('Should run filter:api.user.signup.allowed.result', function () {
410 418
419 before(async function () {
420 await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: false } } })
421 })
422
411 it('Should run on config endpoint', async function () { 423 it('Should run on config endpoint', async function () {
412 const body = await servers[0].config.getConfig() 424 const body = await servers[0].config.getConfig()
413 expect(body.signup.allowed).to.be.true 425 expect(body.signup.allowed).to.be.true
414 }) 426 })
415 427
416 it('Should allow a signup', async function () { 428 it('Should allow a signup', async function () {
417 await servers[0].users.register({ username: 'john', password: 'password' }) 429 await servers[0].registrations.register({ username: 'john1' })
418 }) 430 })
419 431
420 it('Should not allow a signup', async function () { 432 it('Should not allow a signup', async function () {
421 const res = await servers[0].users.register({ 433 const res = await servers[0].registrations.register({
422 username: 'jma', 434 username: 'jma 1',
423 password: 'password',
424 expectedStatus: HttpStatusCode.FORBIDDEN_403 435 expectedStatus: HttpStatusCode.FORBIDDEN_403
425 }) 436 })
426 437
427 expect(res.body.error).to.equal('No jma') 438 expect(res.body.error).to.equal('No jma 1')
439 })
440 })
441
442 describe('Should run filter:api.user.request-signup.allowed.result', function () {
443
444 before(async function () {
445 await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: true } } })
446 })
447
448 it('Should run on config endpoint', async function () {
449 const body = await servers[0].config.getConfig()
450 expect(body.signup.allowed).to.be.true
451 })
452
453 it('Should allow a signup request', async function () {
454 await servers[0].registrations.requestRegistration({ username: 'john2', registrationReason: 'tt' })
455 })
456
457 it('Should not allow a signup request', async function () {
458 const body = await servers[0].registrations.requestRegistration({
459 username: 'jma 2',
460 registrationReason: 'tt',
461 expectedStatus: HttpStatusCode.FORBIDDEN_403
462 })
463
464 expect((body as unknown as PeerTubeProblemDocument).error).to.equal('No jma 2')
428 }) 465 })
429 }) 466 })
430 467
431 describe('Download hooks', function () { 468 describe('Download hooks', function () {
432 const downloadVideos: VideoDetails[] = [] 469 const downloadVideos: VideoDetails[] = []
470 let downloadVideo2Token: string
433 471
434 before(async function () { 472 before(async function () {
435 this.timeout(120000) 473 this.timeout(120000)
@@ -459,6 +497,8 @@ describe('Test plugin filter hooks', function () {
459 for (const uuid of uuids) { 497 for (const uuid of uuids) {
460 downloadVideos.push(await servers[0].videos.get({ id: uuid })) 498 downloadVideos.push(await servers[0].videos.get({ id: uuid }))
461 } 499 }
500
501 downloadVideo2Token = await servers[0].videoToken.getVideoFileToken({ videoId: downloadVideos[2].uuid })
462 }) 502 })
463 503
464 it('Should run filter:api.download.torrent.allowed.result', async function () { 504 it('Should run filter:api.download.torrent.allowed.result', async function () {
@@ -471,32 +511,42 @@ describe('Test plugin filter hooks', function () {
471 511
472 it('Should run filter:api.download.video.allowed.result', async function () { 512 it('Should run filter:api.download.video.allowed.result', async function () {
473 { 513 {
474 const res = await makeRawRequest({ url: downloadVideos[1].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 514 const refused = downloadVideos[1].files[0].fileDownloadUrl
515 const allowed = [
516 downloadVideos[0].files[0].fileDownloadUrl,
517 downloadVideos[2].files[0].fileDownloadUrl
518 ]
519
520 const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
475 expect(res.body.error).to.equal('Cao Cao') 521 expect(res.body.error).to.equal('Cao Cao')
476 522
477 await makeRawRequest({ url: downloadVideos[0].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 523 for (const url of allowed) {
478 await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 524 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
525 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
526 }
479 } 527 }
480 528
481 { 529 {
482 const res = await makeRawRequest({ 530 const refused = downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl
483 url: downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl,
484 expectedStatus: HttpStatusCode.FORBIDDEN_403
485 })
486 531
487 expect(res.body.error).to.equal('Sun Jian') 532 const allowed = [
533 downloadVideos[2].files[0].fileDownloadUrl,
534 downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
535 downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl
536 ]
488 537
489 await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 538 // Only streaming playlist is refuse
539 const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
540 expect(res.body.error).to.equal('Sun Jian')
490 541
491 await makeRawRequest({ 542 // But not we there is a user in res
492 url: downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, 543 await makeRawRequest({ url: refused, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 })
493 expectedStatus: HttpStatusCode.OK_200 544 await makeRawRequest({ url: refused, query: { videoFileToken: downloadVideo2Token }, expectedStatus: HttpStatusCode.OK_200 })
494 })
495 545
496 await makeRawRequest({ 546 // Other files work
497 url: downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl, 547 for (const url of allowed) {
498 expectedStatus: HttpStatusCode.OK_200 548 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
499 }) 549 }
500 } 550 }
501 }) 551 })
502 }) 552 })
diff --git a/server/tests/plugins/id-and-pass-auth.ts b/server/tests/plugins/id-and-pass-auth.ts
index fc24a5656..10155c28b 100644
--- a/server/tests/plugins/id-and-pass-auth.ts
+++ b/server/tests/plugins/id-and-pass-auth.ts
@@ -13,6 +13,7 @@ describe('Test id and pass auth plugins', function () {
13 13
14 let lagunaAccessToken: string 14 let lagunaAccessToken: string
15 let lagunaRefreshToken: string 15 let lagunaRefreshToken: string
16 let lagunaId: number
16 17
17 before(async function () { 18 before(async function () {
18 this.timeout(30000) 19 this.timeout(30000)
@@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () {
78 const body = await server.users.getMyInfo({ token: lagunaAccessToken }) 79 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
79 80
80 expect(body.username).to.equal('laguna') 81 expect(body.username).to.equal('laguna')
81 expect(body.account.displayName).to.equal('laguna') 82 expect(body.account.displayName).to.equal('Laguna Loire')
82 expect(body.role.id).to.equal(UserRole.USER) 83 expect(body.role.id).to.equal(UserRole.USER)
84
85 lagunaId = body.id
83 } 86 }
84 }) 87 })
85 88
@@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () {
132 expect(body.role.id).to.equal(UserRole.MODERATOR) 135 expect(body.role.id).to.equal(UserRole.MODERATOR)
133 }) 136 })
134 137
138 it('Should login Laguna and update the profile', async function () {
139 {
140 await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 })
141 await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' })
142
143 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
144 expect(body.username).to.equal('laguna')
145 expect(body.account.displayName).to.equal('laguna updated')
146 expect(body.videoQuota).to.equal(43000)
147 expect(body.videoQuotaDaily).to.equal(43100)
148 }
149
150 {
151 const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } })
152 lagunaAccessToken = body.access_token
153 lagunaRefreshToken = body.refresh_token
154 }
155
156 {
157 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
158 expect(body.username).to.equal('laguna')
159 expect(body.account.displayName).to.equal('Laguna Loire')
160 expect(body.videoQuota).to.equal(42000)
161 expect(body.videoQuotaDaily).to.equal(43100)
162 }
163 })
164
135 it('Should reject token of laguna by the plugin hook', async function () { 165 it('Should reject token of laguna by the plugin hook', async function () {
136 this.timeout(10000) 166 this.timeout(10000)
137 167
@@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () {
147 await server.servers.waitUntilLog('valid username') 177 await server.servers.waitUntilLog('valid username')
148 178
149 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 179 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
150 await server.servers.waitUntilLog('valid display name') 180 await server.servers.waitUntilLog('valid displayName')
151 181
152 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 182 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
153 await server.servers.waitUntilLog('valid role') 183 await server.servers.waitUntilLog('valid role')
diff --git a/server/tests/plugins/plugin-helpers.ts b/server/tests/plugins/plugin-helpers.ts
index 038e3f0d6..e25992723 100644
--- a/server/tests/plugins/plugin-helpers.ts
+++ b/server/tests/plugins/plugin-helpers.ts
@@ -64,6 +64,18 @@ describe('Test plugin helpers', function () {
64 await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`) 64 await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`)
65 }) 65 })
66 66
67 it('Should have the correct listening config', async function () {
68 const res = await makeGetRequest({
69 url: servers[0].url,
70 path: '/plugins/test-four/router/server-listening-config',
71 expectedStatus: HttpStatusCode.OK_200
72 })
73
74 expect(res.body.config).to.exist
75 expect(res.body.config.hostname).to.equal('::')
76 expect(res.body.config.port).to.equal(servers[0].port)
77 })
78
67 it('Should have the correct config', async function () { 79 it('Should have the correct config', async function () {
68 const res = await makeGetRequest({ 80 const res = await makeGetRequest({
69 url: servers[0].url, 81 url: servers[0].url,
diff --git a/server/tests/shared/notifications.ts b/server/tests/shared/notifications.ts
index e600bd6b2..6c0688d5a 100644
--- a/server/tests/shared/notifications.ts
+++ b/server/tests/shared/notifications.ts
@@ -11,6 +11,7 @@ import {
11 UserNotificationType 11 UserNotificationType
12} from '@shared/models' 12} from '@shared/models'
13import { 13import {
14 ConfigCommand,
14 createMultipleServers, 15 createMultipleServers,
15 doubleFollow, 16 doubleFollow,
16 PeerTubeServer, 17 PeerTubeServer,
@@ -173,6 +174,8 @@ async function checkMyVideoImportIsFinished (options: CheckerBaseParams & {
173 await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) 174 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
174} 175}
175 176
177// ---------------------------------------------------------------------------
178
176async function checkUserRegistered (options: CheckerBaseParams & { 179async function checkUserRegistered (options: CheckerBaseParams & {
177 username: string 180 username: string
178 checkType: CheckerType 181 checkType: CheckerType
@@ -201,6 +204,36 @@ async function checkUserRegistered (options: CheckerBaseParams & {
201 await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) 204 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
202} 205}
203 206
207async function checkRegistrationRequest (options: CheckerBaseParams & {
208 username: string
209 registrationReason: string
210 checkType: CheckerType
211}) {
212 const { username, registrationReason } = options
213 const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST
214
215 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
216 if (checkType === 'presence') {
217 expect(notification).to.not.be.undefined
218 expect(notification.type).to.equal(notificationType)
219
220 expect(notification.registration.username).to.equal(username)
221 } else {
222 expect(notification).to.satisfy(n => n.type !== notificationType || n.registration.username !== username)
223 }
224 }
225
226 function emailNotificationFinder (email: object) {
227 const text: string = email['text']
228
229 return text.includes(' wants to register ') && text.includes(username) && text.includes(registrationReason)
230 }
231
232 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
233}
234
235// ---------------------------------------------------------------------------
236
204async function checkNewActorFollow (options: CheckerBaseParams & { 237async function checkNewActorFollow (options: CheckerBaseParams & {
205 followType: 'channel' | 'account' 238 followType: 'channel' | 'account'
206 followerName: string 239 followerName: string
@@ -673,10 +706,8 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
673 const port = await MockSmtpServer.Instance.collectEmails(emails) 706 const port = await MockSmtpServer.Instance.collectEmails(emails)
674 707
675 const overrideConfig = { 708 const overrideConfig = {
676 smtp: { 709 ...ConfigCommand.getEmailOverrideConfig(port),
677 hostname: '127.0.0.1', 710
678 port
679 },
680 signup: { 711 signup: {
681 limit: 20 712 limit: 20
682 } 713 }
@@ -735,7 +766,8 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
735 userAccessToken, 766 userAccessToken,
736 emails, 767 emails,
737 servers, 768 servers,
738 channelId 769 channelId,
770 baseOverrideConfig: overrideConfig
739 } 771 }
740} 772}
741 773
@@ -765,7 +797,8 @@ export {
765 checkNewAccountAbuseForModerators, 797 checkNewAccountAbuseForModerators,
766 checkNewPeerTubeVersion, 798 checkNewPeerTubeVersion,
767 checkNewPluginVersion, 799 checkNewPluginVersion,
768 checkVideoStudioEditionIsFinished 800 checkVideoStudioEditionIsFinished,
801 checkRegistrationRequest
769} 802}
770 803
771// --------------------------------------------------------------------------- 804// ---------------------------------------------------------------------------
diff --git a/server/tests/shared/videos.ts b/server/tests/shared/videos.ts
index c8339584b..f8ec65752 100644
--- a/server/tests/shared/videos.ts
+++ b/server/tests/shared/videos.ts
@@ -59,7 +59,7 @@ async function completeVideoCheck (
59 59
60 expect(video.name).to.equal(attributes.name) 60 expect(video.name).to.equal(attributes.name)
61 expect(video.category.id).to.equal(attributes.category) 61 expect(video.category.id).to.equal(attributes.category)
62 expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Misc') 62 expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Unknown')
63 expect(video.licence.id).to.equal(attributes.licence) 63 expect(video.licence.id).to.equal(attributes.licence)
64 expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown') 64 expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown')
65 expect(video.language.id).to.equal(attributes.language) 65 expect(video.language.id).to.equal(attributes.language)
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 3738ffc47..c1c379b98 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -1,4 +1,3 @@
1
2import { OutgoingHttpHeaders } from 'http' 1import { OutgoingHttpHeaders } from 'http'
3import { RegisterServerAuthExternalOptions } from '@server/types' 2import { RegisterServerAuthExternalOptions } from '@server/types'
4import { 3import {
@@ -9,7 +8,9 @@ import {
9 MActorUrl, 8 MActorUrl,
10 MChannelBannerAccountDefault, 9 MChannelBannerAccountDefault,
11 MChannelSyncChannel, 10 MChannelSyncChannel,
11 MRegistration,
12 MStreamingPlaylist, 12 MStreamingPlaylist,
13 MUserAccountUrl,
13 MVideoChangeOwnershipFull, 14 MVideoChangeOwnershipFull,
14 MVideoFile, 15 MVideoFile,
15 MVideoFormattableDetails, 16 MVideoFormattableDetails,
@@ -171,6 +172,7 @@ declare module 'express' {
171 actorFull?: MActorFull 172 actorFull?: MActorFull
172 173
173 user?: MUserDefault 174 user?: MUserDefault
175 userRegistration?: MRegistration
174 176
175 server?: MServer 177 server?: MServer
176 178
@@ -187,6 +189,10 @@ declare module 'express' {
187 actor: MActorAccountChannelId 189 actor: MActorAccountChannelId
188 } 190 }
189 191
192 videoFileToken?: {
193 user: MUserAccountUrl
194 }
195
190 authenticated?: boolean 196 authenticated?: boolean
191 197
192 registeredPlugin?: RegisteredPlugin 198 registeredPlugin?: RegisteredPlugin
diff --git a/server/types/lib.d.ts b/server/types/lib.d.ts
new file mode 100644
index 000000000..c901e2032
--- /dev/null
+++ b/server/types/lib.d.ts
@@ -0,0 +1,12 @@
1type ObjectKeys<T> =
2 T extends object
3 ? `${Exclude<keyof T, symbol>}`[]
4 : T extends number
5 ? []
6 : T extends any | string
7 ? string[]
8 : never
9
10interface ObjectConstructor {
11 keys<T> (o: T): ObjectKeys<T>
12}
diff --git a/server/types/models/user/index.ts b/server/types/models/user/index.ts
index 6657b2128..5738f4107 100644
--- a/server/types/models/user/index.ts
+++ b/server/types/models/user/index.ts
@@ -1,4 +1,5 @@
1export * from './user' 1export * from './user'
2export * from './user-notification' 2export * from './user-notification'
3export * from './user-notification-setting' 3export * from './user-notification-setting'
4export * from './user-registration'
4export * from './user-video-history' 5export * from './user-video-history'
diff --git a/server/types/models/user/user-notification.ts b/server/types/models/user/user-notification.ts
index d4715a0b6..a732c8aa9 100644
--- a/server/types/models/user/user-notification.ts
+++ b/server/types/models/user/user-notification.ts
@@ -3,6 +3,7 @@ import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse
3import { ApplicationModel } from '@server/models/application/application' 3import { ApplicationModel } from '@server/models/application/application'
4import { PluginModel } from '@server/models/server/plugin' 4import { PluginModel } from '@server/models/server/plugin'
5import { UserNotificationModel } from '@server/models/user/user-notification' 5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { UserRegistrationModel } from '@server/models/user/user-registration'
6import { PickWith, PickWithOpt } from '@shared/typescript-utils' 7import { PickWith, PickWithOpt } from '@shared/typescript-utils'
7import { AbuseModel } from '../../../models/abuse/abuse' 8import { AbuseModel } from '../../../models/abuse/abuse'
8import { AccountModel } from '../../../models/account/account' 9import { AccountModel } from '../../../models/account/account'
@@ -94,13 +95,16 @@ export module UserNotificationIncludes {
94 95
95 export type ApplicationInclude = 96 export type ApplicationInclude =
96 Pick<ApplicationModel, 'latestPeerTubeVersion'> 97 Pick<ApplicationModel, 'latestPeerTubeVersion'>
98
99 export type UserRegistrationInclude =
100 Pick<UserRegistrationModel, 'id' | 'username'>
97} 101}
98 102
99// ############################################################################ 103// ############################################################################
100 104
101export type MUserNotification = 105export type MUserNotification =
102 Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' | 106 Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' |
103 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application'> 107 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application' | 'UserRegistration'>
104 108
105// ############################################################################ 109// ############################################################################
106 110
@@ -114,4 +118,5 @@ export type UserNotificationModelForApi =
114 Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> & 118 Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> &
115 Use<'Plugin', UserNotificationIncludes.PluginInclude> & 119 Use<'Plugin', UserNotificationIncludes.PluginInclude> &
116 Use<'Application', UserNotificationIncludes.ApplicationInclude> & 120 Use<'Application', UserNotificationIncludes.ApplicationInclude> &
117 Use<'Account', UserNotificationIncludes.AccountIncludeActor> 121 Use<'Account', UserNotificationIncludes.AccountIncludeActor> &
122 Use<'UserRegistration', UserNotificationIncludes.UserRegistrationInclude>
diff --git a/server/types/models/user/user-registration.ts b/server/types/models/user/user-registration.ts
new file mode 100644
index 000000000..216423cc9
--- /dev/null
+++ b/server/types/models/user/user-registration.ts
@@ -0,0 +1,15 @@
1import { UserRegistrationModel } from '@server/models/user/user-registration'
2import { PickWith } from '@shared/typescript-utils'
3import { MUserId } from './user'
4
5type Use<K extends keyof UserRegistrationModel, M> = PickWith<UserRegistrationModel, K, M>
6
7// ############################################################################
8
9export type MRegistration = Omit<UserRegistrationModel, 'User'>
10
11// ############################################################################
12
13export type MRegistrationFormattable =
14 MRegistration &
15 Use<'User', MUserId>
diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts
index 79c18c406..e10968c20 100644
--- a/server/types/plugins/register-server-auth.model.ts
+++ b/server/types/plugins/register-server-auth.model.ts
@@ -1,14 +1,33 @@
1import express from 'express' 1import express from 'express'
2import { UserRole } from '@shared/models' 2import { UserAdminFlag, UserRole } from '@shared/models'
3import { MOAuthToken, MUser } from '../models' 3import { MOAuthToken, MUser } from '../models'
4 4
5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions 5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
6 6
7export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily'
8
7export interface RegisterServerAuthenticatedResult { 9export interface RegisterServerAuthenticatedResult {
10 // Update the user profile if it already exists
11 // Default behaviour is no update
12 // Introduced in PeerTube >= 5.1
13 userUpdater?: <T> (options: {
14 fieldName: AuthenticatedResultUpdaterFieldName
15 currentValue: T
16 newValue: T
17 }) => T
18
8 username: string 19 username: string
9 email: string 20 email: string
10 role?: UserRole 21 role?: UserRole
11 displayName?: string 22 displayName?: string
23
24 // PeerTube >= 5.1
25 adminFlags?: UserAdminFlag
26
27 // PeerTube >= 5.1
28 videoQuota?: number
29 // PeerTube >= 5.1
30 videoQuotaDaily?: number
12} 31}
13 32
14export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult { 33export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
diff --git a/server/types/plugins/register-server-option.model.ts b/server/types/plugins/register-server-option.model.ts
index 1e2bd830e..df419fff4 100644
--- a/server/types/plugins/register-server-option.model.ts
+++ b/server/types/plugins/register-server-option.model.ts
@@ -71,6 +71,9 @@ export type PeerTubeHelpers = {
71 config: { 71 config: {
72 getWebserverUrl: () => string 72 getWebserverUrl: () => string
73 73
74 // PeerTube >= 5.1
75 getServerListeningConfig: () => { hostname: string, port: number }
76
74 getServerConfig: () => Promise<ServerConfig> 77 getServerConfig: () => Promise<ServerConfig>
75 } 78 }
76 79
diff --git a/shared/core-utils/common/version.ts b/shared/core-utils/common/version.ts
index 8a64f8c4d..305287233 100644
--- a/shared/core-utils/common/version.ts
+++ b/shared/core-utils/common/version.ts
@@ -1,18 +1,9 @@
1// Thanks https://stackoverflow.com/a/16187766 1// Thanks https://gist.github.com/iwill/a83038623ba4fef6abb9efca87ae9ccb
2function compareSemVer (a: string, b: string) { 2function compareSemVer (a: string, b: string) {
3 const regExStrip0 = /(\.0+)+$/ 3 if (a.startsWith(b + '-')) return -1
4 const segmentsA = a.replace(regExStrip0, '').split('.') 4 if (b.startsWith(a + '-')) return 1
5 const segmentsB = b.replace(regExStrip0, '').split('.')
6 5
7 const l = Math.min(segmentsA.length, segmentsB.length) 6 return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' })
8
9 for (let i = 0; i < l; i++) {
10 const diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10)
11
12 if (diff) return diff
13 }
14
15 return segmentsA.length - segmentsB.length
16} 7}
17 8
18export { 9export {
diff --git a/shared/core-utils/plugins/hooks.ts b/shared/core-utils/plugins/hooks.ts
index 3784969b5..96bcc945e 100644
--- a/shared/core-utils/plugins/hooks.ts
+++ b/shared/core-utils/plugins/hooks.ts
@@ -1,3 +1,4 @@
1import { RegisteredExternalAuthConfig } from '@shared/models'
1import { HookType } from '../../models/plugins/hook-type.enum' 2import { HookType } from '../../models/plugins/hook-type.enum'
2import { isCatchable, isPromise } from '../common/promises' 3import { isCatchable, isPromise } from '../common/promises'
3 4
@@ -49,7 +50,12 @@ async function internalRunHook <T> (options: {
49 return result 50 return result
50} 51}
51 52
53function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) {
54 return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
55}
56
52export { 57export {
53 getHookType, 58 getHookType,
54 internalRunHook 59 internalRunHook,
60 getExternalAuthHref
55} 61}
diff --git a/shared/core-utils/renderer/html.ts b/shared/core-utils/renderer/html.ts
index 502308979..877f2ec55 100644
--- a/shared/core-utils/renderer/html.ts
+++ b/shared/core-utils/renderer/html.ts
@@ -38,7 +38,11 @@ export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[]
38 ...additionalAllowedTags, 38 ...additionalAllowedTags,
39 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img' 39 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img'
40 ], 40 ],
41 allowedSchemes: base.allowedSchemes, 41 allowedSchemes: [
42 ...base.allowedSchemes,
43
44 'mailto'
45 ],
42 allowedAttributes: { 46 allowedAttributes: {
43 ...base.allowedAttributes, 47 ...base.allowedAttributes,
44 48
diff --git a/shared/core-utils/users/user-role.ts b/shared/core-utils/users/user-role.ts
index cc757d779..5f3b9a10f 100644
--- a/shared/core-utils/users/user-role.ts
+++ b/shared/core-utils/users/user-role.ts
@@ -23,7 +23,8 @@ const userRoleRights: { [ id in UserRole ]: UserRight[] } = {
23 UserRight.MANAGE_ACCOUNTS_BLOCKLIST, 23 UserRight.MANAGE_ACCOUNTS_BLOCKLIST,
24 UserRight.MANAGE_SERVERS_BLOCKLIST, 24 UserRight.MANAGE_SERVERS_BLOCKLIST,
25 UserRight.MANAGE_USERS, 25 UserRight.MANAGE_USERS,
26 UserRight.SEE_ALL_COMMENTS 26 UserRight.SEE_ALL_COMMENTS,
27 UserRight.MANAGE_REGISTRATIONS
27 ], 28 ],
28 29
29 [UserRole.USER]: [] 30 [UserRole.USER]: []
diff --git a/shared/models/plugins/server/server-hook.model.ts b/shared/models/plugins/server/server-hook.model.ts
index f11d2050b..dd9cc3ad6 100644
--- a/shared/models/plugins/server/server-hook.model.ts
+++ b/shared/models/plugins/server/server-hook.model.ts
@@ -91,6 +91,10 @@ export const serverFilterHookObject = {
91 // Filter result used to check if a user can register on the instance 91 // Filter result used to check if a user can register on the instance
92 'filter:api.user.signup.allowed.result': true, 92 'filter:api.user.signup.allowed.result': true,
93 93
94 // Filter result used to check if a user can send a registration request on the instance
95 // PeerTube >= 5.1
96 'filter:api.user.request-signup.allowed.result': true,
97
94 // Filter result used to check if video/torrent download is allowed 98 // Filter result used to check if video/torrent download is allowed
95 'filter:api.download.video.allowed.result': true, 99 'filter:api.download.video.allowed.result': true,
96 'filter:api.download.torrent.allowed.result': true, 100 'filter:api.download.torrent.allowed.result': true,
@@ -156,6 +160,9 @@ export const serverActionHookObject = {
156 'action:api.user.unblocked': true, 160 'action:api.user.unblocked': true,
157 // Fired when a user registered on the instance 161 // Fired when a user registered on the instance
158 'action:api.user.registered': true, 162 'action:api.user.registered': true,
163 // Fired when a user requested registration on the instance
164 // PeerTube >= 5.1
165 'action:api.user.requested-registration': true,
159 // Fired when an admin/moderator created a user 166 // Fired when an admin/moderator created a user
160 'action:api.user.created': true, 167 'action:api.user.created': true,
161 // Fired when a user is removed by an admin/moderator 168 // Fired when a user is removed by an admin/moderator
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index 7d9d570b1..846bf6159 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -83,6 +83,7 @@ export interface CustomConfig {
83 signup: { 83 signup: {
84 enabled: boolean 84 enabled: boolean
85 limit: number 85 limit: number
86 requiresApproval: boolean
86 requiresEmailVerification: boolean 87 requiresEmailVerification: boolean
87 minimumAge: number 88 minimumAge: number
88 } 89 }
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index 3b6d0597c..d0bd9a00f 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -131,6 +131,7 @@ export interface ServerConfig {
131 allowed: boolean 131 allowed: boolean
132 allowedForCurrentIP: boolean 132 allowedForCurrentIP: boolean
133 requiresEmailVerification: boolean 133 requiresEmailVerification: boolean
134 requiresApproval: boolean
134 minimumAge: number 135 minimumAge: number
135 } 136 }
136 137
diff --git a/shared/models/server/server-error-code.enum.ts b/shared/models/server/server-error-code.enum.ts
index 0e70ea0a7..a39cde1b3 100644
--- a/shared/models/server/server-error-code.enum.ts
+++ b/shared/models/server/server-error-code.enum.ts
@@ -39,7 +39,13 @@ export const enum ServerErrorCode {
39 */ 39 */
40 INCORRECT_FILES_IN_TORRENT = 'incorrect_files_in_torrent', 40 INCORRECT_FILES_IN_TORRENT = 'incorrect_files_in_torrent',
41 41
42 COMMENT_NOT_ASSOCIATED_TO_VIDEO = 'comment_not_associated_to_video' 42 COMMENT_NOT_ASSOCIATED_TO_VIDEO = 'comment_not_associated_to_video',
43
44 MISSING_TWO_FACTOR = 'missing_two_factor',
45 INVALID_TWO_FACTOR = 'invalid_two_factor',
46
47 ACCOUNT_WAITING_FOR_APPROVAL = 'account_waiting_for_approval',
48 ACCOUNT_APPROVAL_REJECTED = 'account_approval_rejected'
43} 49}
44 50
45/** 51/**
@@ -70,5 +76,5 @@ export const enum OAuth2ErrorCode {
70 * 76 *
71 * @see https://github.com/oauthjs/node-oauth2-server/blob/master/lib/errors/invalid-token-error.js 77 * @see https://github.com/oauthjs/node-oauth2-server/blob/master/lib/errors/invalid-token-error.js
72 */ 78 */
73 INVALID_TOKEN = 'invalid_token', 79 INVALID_TOKEN = 'invalid_token'
74} 80}
diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts
index 32f7a441c..4a050c870 100644
--- a/shared/models/users/index.ts
+++ b/shared/models/users/index.ts
@@ -1,3 +1,4 @@
1export * from './registration'
1export * from './two-factor-enable-result.model' 2export * from './two-factor-enable-result.model'
2export * from './user-create-result.model' 3export * from './user-create-result.model'
3export * from './user-create.model' 4export * from './user-create.model'
@@ -6,7 +7,6 @@ export * from './user-login.model'
6export * from './user-notification-setting.model' 7export * from './user-notification-setting.model'
7export * from './user-notification.model' 8export * from './user-notification.model'
8export * from './user-refresh-token.model' 9export * from './user-refresh-token.model'
9export * from './user-register.model'
10export * from './user-right.enum' 10export * from './user-right.enum'
11export * from './user-role' 11export * from './user-role'
12export * from './user-scoped-token' 12export * from './user-scoped-token'
diff --git a/shared/models/users/registration/index.ts b/shared/models/users/registration/index.ts
new file mode 100644
index 000000000..593740c4f
--- /dev/null
+++ b/shared/models/users/registration/index.ts
@@ -0,0 +1,5 @@
1export * from './user-register.model'
2export * from './user-registration-request.model'
3export * from './user-registration-state.model'
4export * from './user-registration-update-state.model'
5export * from './user-registration.model'
diff --git a/shared/models/users/user-register.model.ts b/shared/models/users/registration/user-register.model.ts
index cf9a43a67..cf9a43a67 100644
--- a/shared/models/users/user-register.model.ts
+++ b/shared/models/users/registration/user-register.model.ts
diff --git a/shared/models/users/registration/user-registration-request.model.ts b/shared/models/users/registration/user-registration-request.model.ts
new file mode 100644
index 000000000..6c38817e0
--- /dev/null
+++ b/shared/models/users/registration/user-registration-request.model.ts
@@ -0,0 +1,5 @@
1import { UserRegister } from './user-register.model'
2
3export interface UserRegistrationRequest extends UserRegister {
4 registrationReason: string
5}
diff --git a/shared/models/users/registration/user-registration-state.model.ts b/shared/models/users/registration/user-registration-state.model.ts
new file mode 100644
index 000000000..e4c835f78
--- /dev/null
+++ b/shared/models/users/registration/user-registration-state.model.ts
@@ -0,0 +1,5 @@
1export const enum UserRegistrationState {
2 PENDING = 1,
3 REJECTED = 2,
4 ACCEPTED = 3
5}
diff --git a/shared/models/users/registration/user-registration-update-state.model.ts b/shared/models/users/registration/user-registration-update-state.model.ts
new file mode 100644
index 000000000..a1740dcca
--- /dev/null
+++ b/shared/models/users/registration/user-registration-update-state.model.ts
@@ -0,0 +1,4 @@
1export interface UserRegistrationUpdateState {
2 moderationResponse: string
3 preventEmailDelivery?: boolean
4}
diff --git a/shared/models/users/registration/user-registration.model.ts b/shared/models/users/registration/user-registration.model.ts
new file mode 100644
index 000000000..0d74dc28b
--- /dev/null
+++ b/shared/models/users/registration/user-registration.model.ts
@@ -0,0 +1,29 @@
1import { UserRegistrationState } from './user-registration-state.model'
2
3export interface UserRegistration {
4 id: number
5
6 state: {
7 id: UserRegistrationState
8 label: string
9 }
10
11 registrationReason: string
12 moderationResponse: string
13
14 username: string
15 email: string
16 emailVerified: boolean
17
18 accountDisplayName: string
19
20 channelHandle: string
21 channelDisplayName: string
22
23 createdAt: Date
24 updatedAt: Date
25
26 user?: {
27 id: number
28 }
29}
diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts
index 0fd7a7181..294c921bd 100644
--- a/shared/models/users/user-notification.model.ts
+++ b/shared/models/users/user-notification.model.ts
@@ -32,7 +32,9 @@ export const enum UserNotificationType {
32 NEW_PLUGIN_VERSION = 17, 32 NEW_PLUGIN_VERSION = 17,
33 NEW_PEERTUBE_VERSION = 18, 33 NEW_PEERTUBE_VERSION = 18,
34 34
35 MY_VIDEO_STUDIO_EDITION_FINISHED = 19 35 MY_VIDEO_STUDIO_EDITION_FINISHED = 19,
36
37 NEW_USER_REGISTRATION_REQUEST = 20
36} 38}
37 39
38export interface VideoInfo { 40export interface VideoInfo {
@@ -126,6 +128,11 @@ export interface UserNotification {
126 latestVersion: string 128 latestVersion: string
127 } 129 }
128 130
131 registration?: {
132 id: number
133 username: string
134 }
135
129 createdAt: string 136 createdAt: string
130 updatedAt: string 137 updatedAt: string
131} 138}
diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts
index 9c6828aa5..42e5c8cd6 100644
--- a/shared/models/users/user-right.enum.ts
+++ b/shared/models/users/user-right.enum.ts
@@ -43,5 +43,7 @@ export const enum UserRight {
43 MANAGE_VIDEO_FILES = 25, 43 MANAGE_VIDEO_FILES = 25,
44 RUN_VIDEO_TRANSCODING = 26, 44 RUN_VIDEO_TRANSCODING = 26,
45 45
46 MANAGE_VIDEO_IMPORTS = 27 46 MANAGE_VIDEO_IMPORTS = 27,
47
48 MANAGE_REGISTRATIONS = 28
47} 49}
diff --git a/shared/server-commands/miscs/sql-command.ts b/shared/server-commands/miscs/sql-command.ts
index 823fc9e38..35cc2253f 100644
--- a/shared/server-commands/miscs/sql-command.ts
+++ b/shared/server-commands/miscs/sql-command.ts
@@ -13,101 +13,87 @@ export class SQLCommand extends AbstractCommand {
13 return seq.query(`DELETE FROM "${table}"`, options) 13 return seq.query(`DELETE FROM "${table}"`, options)
14 } 14 }
15 15
16 async getCount (table: string) { 16 async getVideoShareCount () {
17 const seq = this.getSequelize() 17 const [ { total } ] = await this.selectQuery<{ total: string }>(`SELECT COUNT(*) as total FROM "videoShare"`)
18
19 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
20
21 const [ { total } ] = await seq.query<{ total: string }>(`SELECT COUNT(*) as total FROM "${table}"`, options)
22 if (total === null) return 0 18 if (total === null) return 0
23 19
24 return parseInt(total, 10) 20 return parseInt(total, 10)
25 } 21 }
26 22
27 async getInternalFileUrl (fileId: number) { 23 async getInternalFileUrl (fileId: number) {
28 return this.selectQuery(`SELECT "fileUrl" FROM "videoFile" WHERE id = ${fileId}`) 24 return this.selectQuery<{ fileUrl: string }>(`SELECT "fileUrl" FROM "videoFile" WHERE id = :fileId`, { fileId })
29 .then(rows => rows[0].fileUrl as string) 25 .then(rows => rows[0].fileUrl)
30 } 26 }
31 27
32 setActorField (to: string, field: string, value: string) { 28 setActorField (to: string, field: string, value: string) {
33 const seq = this.getSequelize() 29 return this.updateQuery(`UPDATE actor SET ${this.escapeColumnName(field)} = :value WHERE url = :to`, { value, to })
34
35 const options = { type: QueryTypes.UPDATE }
36
37 return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options)
38 } 30 }
39 31
40 setVideoField (uuid: string, field: string, value: string) { 32 setVideoField (uuid: string, field: string, value: string) {
41 const seq = this.getSequelize() 33 return this.updateQuery(`UPDATE video SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
42
43 const options = { type: QueryTypes.UPDATE }
44
45 return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
46 } 34 }
47 35
48 setPlaylistField (uuid: string, field: string, value: string) { 36 setPlaylistField (uuid: string, field: string, value: string) {
49 const seq = this.getSequelize() 37 return this.updateQuery(`UPDATE "videoPlaylist" SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
50
51 const options = { type: QueryTypes.UPDATE }
52
53 return seq.query(`UPDATE "videoPlaylist" SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
54 } 38 }
55 39
56 async countVideoViewsOf (uuid: string) { 40 async countVideoViewsOf (uuid: string) {
57 const seq = this.getSequelize()
58
59 const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' + 41 const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' +
60 `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = '${uuid}'` 42 `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = :uuid`
61
62 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
63 const [ { total } ] = await seq.query<{ total: number }>(query, options)
64 43
44 const [ { total } ] = await this.selectQuery<{ total: number }>(query, { uuid })
65 if (!total) return 0 45 if (!total) return 0
66 46
67 return forceNumber(total) 47 return forceNumber(total)
68 } 48 }
69 49
70 getActorImage (filename: string) { 50 getActorImage (filename: string) {
71 return this.selectQuery(`SELECT * FROM "actorImage" WHERE filename = '${filename}'`) 51 return this.selectQuery<{ width: number, height: number }>(`SELECT * FROM "actorImage" WHERE filename = :filename`, { filename })
72 .then(rows => rows[0]) 52 .then(rows => rows[0])
73 } 53 }
74 54
75 selectQuery (query: string) { 55 // ---------------------------------------------------------------------------
76 const seq = this.getSequelize()
77 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
78 56
79 return seq.query<any>(query, options) 57 setPluginVersion (pluginName: string, newVersion: string) {
58 return this.setPluginField(pluginName, 'version', newVersion)
80 } 59 }
81 60
82 updateQuery (query: string) { 61 setPluginLatestVersion (pluginName: string, newVersion: string) {
83 const seq = this.getSequelize() 62 return this.setPluginField(pluginName, 'latestVersion', newVersion)
84 const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE } 63 }
85 64
86 return seq.query(query, options) 65 setPluginField (pluginName: string, field: string, value: string) {
66 return this.updateQuery(
67 `UPDATE "plugin" SET ${this.escapeColumnName(field)} = :value WHERE "name" = :pluginName`,
68 { pluginName, value }
69 )
87 } 70 }
88 71
89 // --------------------------------------------------------------------------- 72 // ---------------------------------------------------------------------------
90 73
91 setPluginField (pluginName: string, field: string, value: string) { 74 selectQuery <T extends object> (query: string, replacements: { [id: string]: string | number } = {}) {
92 const seq = this.getSequelize() 75 const seq = this.getSequelize()
76 const options = {
77 type: QueryTypes.SELECT as QueryTypes.SELECT,
78 replacements
79 }
93 80
94 const options = { type: QueryTypes.UPDATE } 81 return seq.query<T>(query, options)
95
96 return seq.query(`UPDATE "plugin" SET "${field}" = '${value}' WHERE "name" = '${pluginName}'`, options)
97 } 82 }
98 83
99 setPluginVersion (pluginName: string, newVersion: string) { 84 updateQuery (query: string, replacements: { [id: string]: string | number } = {}) {
100 return this.setPluginField(pluginName, 'version', newVersion) 85 const seq = this.getSequelize()
101 } 86 const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE, replacements }
102 87
103 setPluginLatestVersion (pluginName: string, newVersion: string) { 88 return seq.query(query, options)
104 return this.setPluginField(pluginName, 'latestVersion', newVersion)
105 } 89 }
106 90
107 // --------------------------------------------------------------------------- 91 // ---------------------------------------------------------------------------
108 92
109 async getPlaylistInfohash (playlistId: number) { 93 async getPlaylistInfohash (playlistId: number) {
110 const result = await this.selectQuery('SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = ' + playlistId) 94 const query = 'SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = :playlistId'
95
96 const result = await this.selectQuery<{ p2pMediaLoaderInfohashes: string }>(query, { playlistId })
111 if (!result || result.length === 0) return [] 97 if (!result || result.length === 0) return []
112 98
113 return result[0].p2pMediaLoaderInfohashes 99 return result[0].p2pMediaLoaderInfohashes
@@ -116,19 +102,14 @@ export class SQLCommand extends AbstractCommand {
116 // --------------------------------------------------------------------------- 102 // ---------------------------------------------------------------------------
117 103
118 setActorFollowScores (newScore: number) { 104 setActorFollowScores (newScore: number) {
119 const seq = this.getSequelize() 105 return this.updateQuery(`UPDATE "actorFollow" SET "score" = :newScore`, { newScore })
120
121 const options = { type: QueryTypes.UPDATE }
122
123 return seq.query(`UPDATE "actorFollow" SET "score" = ${newScore}`, options)
124 } 106 }
125 107
126 setTokenField (accessToken: string, field: string, value: string) { 108 setTokenField (accessToken: string, field: string, value: string) {
127 const seq = this.getSequelize() 109 return this.updateQuery(
128 110 `UPDATE "oAuthToken" SET ${this.escapeColumnName(field)} = :value WHERE "accessToken" = :accessToken`,
129 const options = { type: QueryTypes.UPDATE } 111 { value, accessToken }
130 112 )
131 return seq.query(`UPDATE "oAuthToken" SET "${field}" = '${value}' WHERE "accessToken" = '${accessToken}'`, options)
132 } 113 }
133 114
134 async cleanup () { 115 async cleanup () {
@@ -157,4 +138,9 @@ export class SQLCommand extends AbstractCommand {
157 return this.sequelize 138 return this.sequelize
158 } 139 }
159 140
141 private escapeColumnName (columnName: string) {
142 return this.getSequelize().escape(columnName)
143 .replace(/^'/, '"')
144 .replace(/'$/, '"')
145 }
160} 146}
diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts
index dc9cf4e01..cb0e1a5fb 100644
--- a/shared/server-commands/requests/requests.ts
+++ b/shared/server-commands/requests/requests.ts
@@ -199,7 +199,7 @@ function buildRequest (req: request.Test, options: CommonRequestParams) {
199 return req.expect((res) => { 199 return req.expect((res) => {
200 if (options.expectedStatus && res.status !== options.expectedStatus) { 200 if (options.expectedStatus && res.status !== options.expectedStatus) {
201 throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` + 201 throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` +
202 `\nThe server responded this error: "${res.body?.error ?? res.text}".\n` + 202 `\nThe server responded: "${res.body?.error ?? res.text}".\n` +
203 'You may take a closer look at the logs. To see how to do so, check out this page: ' + 203 'You may take a closer look at the logs. To see how to do so, check out this page: ' +
204 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs') 204 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs')
205 } 205 }
diff --git a/shared/server-commands/server/config-command.ts b/shared/server-commands/server/config-command.ts
index 1c2315ed1..eb6bb95a5 100644
--- a/shared/server-commands/server/config-command.ts
+++ b/shared/server-commands/server/config-command.ts
@@ -18,6 +18,33 @@ export class ConfigCommand extends AbstractCommand {
18 } 18 }
19 } 19 }
20 20
21 // ---------------------------------------------------------------------------
22
23 static getEmailOverrideConfig (emailPort: number) {
24 return {
25 smtp: {
26 hostname: '127.0.0.1',
27 port: emailPort
28 }
29 }
30 }
31
32 // ---------------------------------------------------------------------------
33
34 enableSignup (requiresApproval: boolean, limit = -1) {
35 return this.updateExistingSubConfig({
36 newConfig: {
37 signup: {
38 enabled: true,
39 requiresApproval,
40 limit
41 }
42 }
43 })
44 }
45
46 // ---------------------------------------------------------------------------
47
21 disableImports () { 48 disableImports () {
22 return this.setImportsEnabled(false) 49 return this.setImportsEnabled(false)
23 } 50 }
@@ -44,6 +71,16 @@ export class ConfigCommand extends AbstractCommand {
44 }) 71 })
45 } 72 }
46 73
74 // ---------------------------------------------------------------------------
75
76 enableChannelSync () {
77 return this.setChannelSyncEnabled(true)
78 }
79
80 disableChannelSync () {
81 return this.setChannelSyncEnabled(false)
82 }
83
47 private setChannelSyncEnabled (enabled: boolean) { 84 private setChannelSyncEnabled (enabled: boolean) {
48 return this.updateExistingSubConfig({ 85 return this.updateExistingSubConfig({
49 newConfig: { 86 newConfig: {
@@ -56,13 +93,7 @@ export class ConfigCommand extends AbstractCommand {
56 }) 93 })
57 } 94 }
58 95
59 enableChannelSync () { 96 // ---------------------------------------------------------------------------
60 return this.setChannelSyncEnabled(true)
61 }
62
63 disableChannelSync () {
64 return this.setChannelSyncEnabled(false)
65 }
66 97
67 enableLive (options: { 98 enableLive (options: {
68 allowReplay?: boolean 99 allowReplay?: boolean
@@ -142,6 +173,8 @@ export class ConfigCommand extends AbstractCommand {
142 }) 173 })
143 } 174 }
144 175
176 // ---------------------------------------------------------------------------
177
145 enableStudio () { 178 enableStudio () {
146 return this.updateExistingSubConfig({ 179 return this.updateExistingSubConfig({
147 newConfig: { 180 newConfig: {
@@ -152,6 +185,8 @@ export class ConfigCommand extends AbstractCommand {
152 }) 185 })
153 } 186 }
154 187
188 // ---------------------------------------------------------------------------
189
155 getConfig (options: OverrideCommandOptions = {}) { 190 getConfig (options: OverrideCommandOptions = {}) {
156 const path = '/api/v1/config' 191 const path = '/api/v1/config'
157 192
@@ -304,6 +339,7 @@ export class ConfigCommand extends AbstractCommand {
304 signup: { 339 signup: {
305 enabled: false, 340 enabled: false,
306 limit: 5, 341 limit: 5,
342 requiresApproval: true,
307 requiresEmailVerification: false, 343 requiresEmailVerification: false,
308 minimumAge: 16 344 minimumAge: 16
309 }, 345 },
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts
index ae1395a74..793fae3a8 100644
--- a/shared/server-commands/server/server.ts
+++ b/shared/server-commands/server/server.ts
@@ -18,6 +18,7 @@ import {
18 BlocklistCommand, 18 BlocklistCommand,
19 LoginCommand, 19 LoginCommand,
20 NotificationsCommand, 20 NotificationsCommand,
21 RegistrationsCommand,
21 SubscriptionsCommand, 22 SubscriptionsCommand,
22 TwoFactorCommand, 23 TwoFactorCommand,
23 UsersCommand 24 UsersCommand
@@ -147,6 +148,7 @@ export class PeerTubeServer {
147 views?: ViewsCommand 148 views?: ViewsCommand
148 twoFactor?: TwoFactorCommand 149 twoFactor?: TwoFactorCommand
149 videoToken?: VideoTokenCommand 150 videoToken?: VideoTokenCommand
151 registrations?: RegistrationsCommand
150 152
151 constructor (options: { serverNumber: number } | { url: string }) { 153 constructor (options: { serverNumber: number } | { url: string }) {
152 if ((options as any).url) { 154 if ((options as any).url) {
@@ -430,5 +432,6 @@ export class PeerTubeServer {
430 this.views = new ViewsCommand(this) 432 this.views = new ViewsCommand(this)
431 this.twoFactor = new TwoFactorCommand(this) 433 this.twoFactor = new TwoFactorCommand(this)
432 this.videoToken = new VideoTokenCommand(this) 434 this.videoToken = new VideoTokenCommand(this)
435 this.registrations = new RegistrationsCommand(this)
433 } 436 }
434} 437}
diff --git a/shared/server-commands/users/index.ts b/shared/server-commands/users/index.ts
index 1afc02dc1..404756539 100644
--- a/shared/server-commands/users/index.ts
+++ b/shared/server-commands/users/index.ts
@@ -4,6 +4,7 @@ export * from './blocklist-command'
4export * from './login' 4export * from './login'
5export * from './login-command' 5export * from './login-command'
6export * from './notifications-command' 6export * from './notifications-command'
7export * from './registrations-command'
7export * from './subscriptions-command' 8export * from './subscriptions-command'
8export * from './two-factor-command' 9export * from './two-factor-command'
9export * from './users-command' 10export * from './users-command'
diff --git a/shared/server-commands/users/registrations-command.ts b/shared/server-commands/users/registrations-command.ts
new file mode 100644
index 000000000..f57f54b34
--- /dev/null
+++ b/shared/server-commands/users/registrations-command.ts
@@ -0,0 +1,151 @@
1import { pick } from '@shared/core-utils'
2import { HttpStatusCode, ResultList, UserRegistration, UserRegistrationRequest, UserRegistrationUpdateState } from '@shared/models'
3import { unwrapBody } from '../requests'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6export class RegistrationsCommand extends AbstractCommand {
7
8 register (options: OverrideCommandOptions & Partial<UserRegistrationRequest> & Pick<UserRegistrationRequest, 'username'>) {
9 const { password = 'password', email = options.username + '@example.com' } = options
10 const path = '/api/v1/users/register'
11
12 return this.postBodyRequest({
13 ...options,
14
15 path,
16 fields: {
17 ...pick(options, [ 'username', 'displayName', 'channel' ]),
18
19 password,
20 email
21 },
22 implicitToken: false,
23 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
24 })
25 }
26
27 requestRegistration (
28 options: OverrideCommandOptions & Partial<UserRegistrationRequest> & Pick<UserRegistrationRequest, 'username' | 'registrationReason'>
29 ) {
30 const { password = 'password', email = options.username + '@example.com' } = options
31 const path = '/api/v1/users/registrations/request'
32
33 return unwrapBody<UserRegistration>(this.postBodyRequest({
34 ...options,
35
36 path,
37 fields: {
38 ...pick(options, [ 'username', 'displayName', 'channel', 'registrationReason' ]),
39
40 password,
41 email
42 },
43 implicitToken: false,
44 defaultExpectedStatus: HttpStatusCode.OK_200
45 }))
46 }
47
48 // ---------------------------------------------------------------------------
49
50 accept (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) {
51 const { id } = options
52 const path = '/api/v1/users/registrations/' + id + '/accept'
53
54 return this.postBodyRequest({
55 ...options,
56
57 path,
58 fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]),
59 implicitToken: true,
60 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
61 })
62 }
63
64 reject (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) {
65 const { id } = options
66 const path = '/api/v1/users/registrations/' + id + '/reject'
67
68 return this.postBodyRequest({
69 ...options,
70
71 path,
72 fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]),
73 implicitToken: true,
74 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
75 })
76 }
77
78 // ---------------------------------------------------------------------------
79
80 delete (options: OverrideCommandOptions & {
81 id: number
82 }) {
83 const { id } = options
84 const path = '/api/v1/users/registrations/' + id
85
86 return this.deleteRequest({
87 ...options,
88
89 path,
90 implicitToken: true,
91 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
92 })
93 }
94
95 // ---------------------------------------------------------------------------
96
97 list (options: OverrideCommandOptions & {
98 start?: number
99 count?: number
100 sort?: string
101 search?: string
102 } = {}) {
103 const path = '/api/v1/users/registrations'
104
105 return this.getRequestBody<ResultList<UserRegistration>>({
106 ...options,
107
108 path,
109 query: pick(options, [ 'start', 'count', 'sort', 'search' ]),
110 implicitToken: true,
111 defaultExpectedStatus: HttpStatusCode.OK_200
112 })
113 }
114
115 // ---------------------------------------------------------------------------
116
117 askSendVerifyEmail (options: OverrideCommandOptions & {
118 email: string
119 }) {
120 const { email } = options
121 const path = '/api/v1/users/registrations/ask-send-verify-email'
122
123 return this.postBodyRequest({
124 ...options,
125
126 path,
127 fields: { email },
128 implicitToken: false,
129 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
130 })
131 }
132
133 verifyEmail (options: OverrideCommandOptions & {
134 registrationId: number
135 verificationString: string
136 }) {
137 const { registrationId, verificationString } = options
138 const path = '/api/v1/users/registrations/' + registrationId + '/verify-email'
139
140 return this.postBodyRequest({
141 ...options,
142
143 path,
144 fields: {
145 verificationString
146 },
147 implicitToken: false,
148 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
149 })
150 }
151}
diff --git a/shared/server-commands/users/users-command.ts b/shared/server-commands/users/users-command.ts
index 811b9685b..8a42fafc8 100644
--- a/shared/server-commands/users/users-command.ts
+++ b/shared/server-commands/users/users-command.ts
@@ -214,35 +214,6 @@ export class UsersCommand extends AbstractCommand {
214 return this.server.login.getAccessToken({ username, password }) 214 return this.server.login.getAccessToken({ username, password })
215 } 215 }
216 216
217 register (options: OverrideCommandOptions & {
218 username: string
219 password?: string
220 displayName?: string
221 email?: string
222 channel?: {
223 name: string
224 displayName: string
225 }
226 }) {
227 const { username, password = 'password', displayName, channel, email = username + '@example.com' } = options
228 const path = '/api/v1/users/register'
229
230 return this.postBodyRequest({
231 ...options,
232
233 path,
234 fields: {
235 username,
236 password,
237 email,
238 displayName,
239 channel
240 },
241 implicitToken: false,
242 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
243 })
244 }
245
246 // --------------------------------------------------------------------------- 217 // ---------------------------------------------------------------------------
247 218
248 getMyInfo (options: OverrideCommandOptions = {}) { 219 getMyInfo (options: OverrideCommandOptions = {}) {
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index bfa7235a2..f90b7f575 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -1,7 +1,7 @@
1openapi: 3.0.0 1openapi: 3.0.0
2info: 2info:
3 title: PeerTube 3 title: PeerTube
4 version: 4.0.0 4 version: 5.1.0
5 contact: 5 contact:
6 name: PeerTube Community 6 name: PeerTube Community
7 url: https://joinpeertube.org 7 url: https://joinpeertube.org
@@ -1401,22 +1401,44 @@ paths:
1401 '200': 1401 '200':
1402 description: successful operation 1402 description: successful operation
1403 1403
1404 /api/v1/users/register: 1404 /api/v1/users/ask-send-verify-email:
1405 post: 1405 post:
1406 summary: Register a user 1406 summary: Resend user verification link
1407 operationId: registerUser 1407 operationId: resendEmailToVerifyUser
1408 tags: 1408 tags:
1409 - Users 1409 - Users
1410 - Register 1410 - Register
1411 requestBody:
1412 content:
1413 application/json:
1414 schema:
1415 type: object
1416 properties:
1417 email:
1418 type: string
1419 description: User email
1411 responses: 1420 responses:
1412 '204': 1421 '204':
1413 description: successful operation 1422 description: successful operation
1423
1424 /api/v1/users/registrations/ask-send-verify-email:
1425 post:
1426 summary: Resend verification link to registration email
1427 operationId: resendEmailToVerifyRegistration
1428 tags:
1429 - Register
1414 requestBody: 1430 requestBody:
1415 content: 1431 content:
1416 application/json: 1432 application/json:
1417 schema: 1433 schema:
1418 $ref: '#/components/schemas/RegisterUser' 1434 type: object
1419 required: true 1435 properties:
1436 email:
1437 type: string
1438 description: Registration email
1439 responses:
1440 '204':
1441 description: successful operation
1420 1442
1421 /api/v1/users/{id}/verify-email: 1443 /api/v1/users/{id}/verify-email:
1422 post: 1444 post:
@@ -1425,6 +1447,7 @@ paths:
1425 description: | 1447 description: |
1426 Following a user registration, the new user will receive an email asking to click a link 1448 Following a user registration, the new user will receive an email asking to click a link
1427 containing a secret. 1449 containing a secret.
1450 This endpoint can also be used to verify a new email set in the user account.
1428 tags: 1451 tags:
1429 - Users 1452 - Users
1430 - Register 1453 - Register
@@ -1451,6 +1474,36 @@ paths:
1451 '404': 1474 '404':
1452 description: user not found 1475 description: user not found
1453 1476
1477 /api/v1/users/registrations/{registrationId}/verify-email:
1478 post:
1479 summary: Verify a registration email
1480 operationId: verifyRegistrationEmail
1481 description: |
1482 Following a user registration request, the user will receive an email asking to click a link
1483 containing a secret.
1484 tags:
1485 - Register
1486 parameters:
1487 - $ref: '#/components/parameters/registrationId'
1488 requestBody:
1489 content:
1490 application/json:
1491 schema:
1492 type: object
1493 properties:
1494 verificationString:
1495 type: string
1496 format: url
1497 required:
1498 - verificationString
1499 responses:
1500 '204':
1501 description: successful operation
1502 '403':
1503 description: invalid verification string
1504 '404':
1505 description: registration not found
1506
1454 /api/v1/users/{id}/two-factor/request: 1507 /api/v1/users/{id}/two-factor/request:
1455 post: 1508 post:
1456 summary: Request two factor auth 1509 summary: Request two factor auth
@@ -1541,18 +1594,6 @@ paths:
1541 '404': 1594 '404':
1542 description: user not found 1595 description: user not found
1543 1596
1544
1545 /api/v1/users/ask-send-verify-email:
1546 post:
1547 summary: Resend user verification link
1548 operationId: resendEmailToVerifyUser
1549 tags:
1550 - Users
1551 - Register
1552 responses:
1553 '204':
1554 description: successful operation
1555
1556 /api/v1/users/me: 1597 /api/v1/users/me:
1557 get: 1598 get:
1558 summary: Get my user information 1599 summary: Get my user information
@@ -2037,6 +2078,146 @@ paths:
2037 '204': 2078 '204':
2038 description: successful operation 2079 description: successful operation
2039 2080
2081 /api/v1/users/register:
2082 post:
2083 summary: Register a user
2084 operationId: registerUser
2085 description: Signup has to be enabled and signup approval is not required
2086 tags:
2087 - Register
2088 responses:
2089 '204':
2090 description: successful operation
2091 '400':
2092 description: request error
2093 '403':
2094 description: user registration is not enabled, user limit is reached, registration is not allowed for the ip, requires approval or blocked by a plugin
2095 '409':
2096 description: 'a user with this username, channel name or email already exists'
2097 requestBody:
2098 content:
2099 application/json:
2100 schema:
2101 $ref: '#/components/schemas/RegisterUser'
2102 required: true
2103
2104 /api/v1/users/registrations/request:
2105 post:
2106 summary: Request registration
2107 description: Signup has to be enabled and require approval on the instance
2108 operationId: requestRegistration
2109 tags:
2110 - Register
2111 responses:
2112 '200':
2113 description: successful operation
2114 content:
2115 application/json:
2116 schema:
2117 $ref: '#/components/schemas/UserRegistration'
2118 '400':
2119 description: request error or signup approval is not enabled on the instance
2120 '403':
2121 description: user registration is not enabled, user limit is reached, registration is not allowed for the ip or blocked by a plugin
2122 '409':
2123 description: 'a user or registration with this username, channel name or email already exists'
2124 requestBody:
2125 content:
2126 application/json:
2127 schema:
2128 $ref: '#/components/schemas/UserRegistrationRequest'
2129
2130 /api/v1/users/registrations/{registrationId}/accept:
2131 post:
2132 security:
2133 - OAuth2:
2134 - admin
2135 - moderator
2136 summary: Accept registration
2137 operationId: acceptRegistration
2138 tags:
2139 - Register
2140 parameters:
2141 - $ref: '#/components/parameters/registrationId'
2142 requestBody:
2143 content:
2144 application/json:
2145 schema:
2146 $ref: '#/components/schemas/UserRegistrationAcceptOrReject'
2147 responses:
2148 '204':
2149 description: successful operation
2150
2151 /api/v1/users/registrations/{registrationId}/reject:
2152 post:
2153 security:
2154 - OAuth2:
2155 - admin
2156 - moderator
2157 summary: Reject registration
2158 operationId: rejectRegistration
2159 tags:
2160 - Register
2161 parameters:
2162 - $ref: '#/components/parameters/registrationId'
2163 requestBody:
2164 content:
2165 application/json:
2166 schema:
2167 $ref: '#/components/schemas/UserRegistrationAcceptOrReject'
2168 responses:
2169 '204':
2170 description: successful operation
2171
2172 /api/v1/users/registrations/{registrationId}:
2173 delete:
2174 security:
2175 - OAuth2:
2176 - admin
2177 - moderator
2178 summary: Delete registration
2179 description: 'Delete the registration entry. It will not remove the user associated with this registration (if any)'
2180 operationId: deleteRegistration
2181 tags:
2182 - Register
2183 parameters:
2184 - $ref: '#/components/parameters/registrationId'
2185 responses:
2186 '204':
2187 description: successful operation
2188
2189 /api/v1/users/registrations:
2190 get:
2191 security:
2192 - OAuth2:
2193 - admin
2194 - moderator
2195 summary: List registrations
2196 operationId: listRegistrations
2197 tags:
2198 - Register
2199 parameters:
2200 - $ref: '#/components/parameters/start'
2201 - $ref: '#/components/parameters/count'
2202 - name: search
2203 in: query
2204 required: false
2205 schema:
2206 type: string
2207 - name: sort
2208 in: query
2209 required: false
2210 schema:
2211 type: string
2212 enum:
2213 - -createdAt
2214 - createdAt
2215 - state
2216 - -state
2217 responses:
2218 '204':
2219 description: successful operation
2220
2040 /api/v1/videos/ownership: 2221 /api/v1/videos/ownership:
2041 get: 2222 get:
2042 summary: List video ownership changes 2223 summary: List video ownership changes
@@ -5389,6 +5570,7 @@ components:
5389 type: string 5570 type: string
5390 enum: 5571 enum:
5391 - createdAt 5572 - createdAt
5573
5392 name: 5574 name:
5393 name: name 5575 name: name
5394 in: path 5576 in: path
@@ -5404,6 +5586,13 @@ components:
5404 description: Entity id 5586 description: Entity id
5405 schema: 5587 schema:
5406 $ref: '#/components/schemas/id' 5588 $ref: '#/components/schemas/id'
5589 registrationId:
5590 name: registrationId
5591 in: path
5592 required: true
5593 description: Registration ID
5594 schema:
5595 $ref: '#/components/schemas/id'
5407 idOrUUID: 5596 idOrUUID:
5408 name: id 5597 name: id
5409 in: path 5598 in: path
@@ -7724,6 +7913,7 @@ components:
7724 required: 7913 required:
7725 - video 7914 - video
7726 - rating 7915 - rating
7916
7727 RegisterUser: 7917 RegisterUser:
7728 properties: 7918 properties:
7729 username: 7919 username:
@@ -7754,6 +7944,77 @@ components:
7754 - password 7944 - password
7755 - email 7945 - email
7756 7946
7947 UserRegistrationRequest:
7948 allOf:
7949 - $ref: '#/components/schemas/RegisterUser'
7950 - type: object
7951 properties:
7952 registrationReason:
7953 type: string
7954 description: reason for the user to register on the instance
7955 required:
7956 - registrationReason
7957
7958 UserRegistrationAcceptOrReject:
7959 type: object
7960 properties:
7961 moderationResponse:
7962 type: string
7963 description: Moderation response to send to the user
7964 preventEmailDelivery:
7965 type: boolean
7966 description: Set it to true if you don't want PeerTube to send an email to the user
7967 required:
7968 - moderationResponse
7969
7970 UserRegistration:
7971 properties:
7972 id:
7973 $ref: '#/components/schemas/id'
7974 state:
7975 type: object
7976 properties:
7977 id:
7978 type: integer
7979 enum:
7980 - 1
7981 - 2
7982 - 3
7983 description: 'The registration state (Pending = `1`, Rejected = `2`, Accepted = `3`)'
7984 label:
7985 type: string
7986 registrationReason:
7987 type: string
7988 moderationResponse:
7989 type: string
7990 nullable: true
7991 username:
7992 type: string
7993 email:
7994 type: string
7995 format: email
7996 emailVerified:
7997 type: boolean
7998 accountDisplayName:
7999 type: string
8000 channelHandle:
8001 type: string
8002 channelDisplayName:
8003 type: string
8004 createdAt:
8005 type: string
8006 format: date-time
8007 updatedAt:
8008 type: string
8009 format: date-time
8010 user:
8011 type: object
8012 nullable: true
8013 description: If the registration has been accepted, this is a partial user object created by the registration
8014 properties:
8015 id:
8016 $ref: '#/components/schemas/id'
8017
7757 OAuthClient: 8018 OAuthClient:
7758 properties: 8019 properties:
7759 client_id: 8020 client_id:
diff --git a/support/doc/dependencies.md b/support/doc/dependencies.md
index bf53b8080..5cf1d5879 100644
--- a/support/doc/dependencies.md
+++ b/support/doc/dependencies.md
@@ -2,8 +2,6 @@
2 2
3:warning: **Warning**: dependencies guide is maintained by the community. Some parts may be outdated! :warning: 3:warning: **Warning**: dependencies guide is maintained by the community. Some parts may be outdated! :warning:
4 4
5Follow the below guides, and check their versions match [required external dependencies versions](https://github.com/Chocobozzz/PeerTube/blob/master/engines.yaml).
6
7Main dependencies version supported by PeerTube: 5Main dependencies version supported by PeerTube:
8 6
9 * `node` >=14.x 7 * `node` >=14.x
diff --git a/support/doc/docker.md b/support/doc/docker.md
index 267863a4d..b6990f3e3 100644
--- a/support/doc/docker.md
+++ b/support/doc/docker.md
@@ -120,7 +120,7 @@ See the production guide ["What now" section](https://docs.joinpeertube.org/inst
120 120
121## Upgrade 121## Upgrade
122 122
123**Important:** Before upgrading, check you have all the `storage` fields in your [production.yaml file](https://github.com/Chocobozzz/PeerTube/blob/master/support/docker/production/config/production.yaml). 123**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
124 124
125Pull the latest images: 125Pull the latest images:
126 126
diff --git a/support/doc/plugins/guide.md b/support/doc/plugins/guide.md
index a1131ced5..9ddab3ece 100644
--- a/support/doc/plugins/guide.md
+++ b/support/doc/plugins/guide.md
@@ -433,7 +433,27 @@ function register (...) {
433 username: 'user' 433 username: 'user'
434 email: 'user@example.com' 434 email: 'user@example.com'
435 role: 2 435 role: 2
436 displayName: 'User display name' 436 displayName: 'User display name',
437
438 // Custom admin flags (bypass video auto moderation etc.)
439 // https://github.com/Chocobozzz/PeerTube/blob/develop/shared/models/users/user-flag.model.ts
440 // PeerTube >= 5.1
441 adminFlags: 0,
442 // Quota in bytes
443 // PeerTube >= 5.1
444 videoQuota: 1024 * 1024 * 1024, // 1GB
445 // PeerTube >= 5.1
446 videoQuotaDaily: -1, // Unlimited
447
448 // Update the user profile if it already exists
449 // Default behaviour is no update
450 // Introduced in PeerTube >= 5.1
451 userUpdater: ({ fieldName, currentValue, newValue }) => {
452 // Always use new value except for videoQuotaDaily field
453 if (fieldName === 'videoQuotaDaily') return currentValue
454
455 return newValue
456 }
437 }) 457 })
438 }) 458 })
439 459
diff --git a/support/doc/production.md b/support/doc/production.md
index dd57e9120..9a84f19a3 100644
--- a/support/doc/production.md
+++ b/support/doc/production.md
@@ -177,16 +177,17 @@ $ sudo vim /etc/letsencrypt/renewal/your-domain.com.conf
177 177
178If you plan to have many concurrent viewers on your PeerTube instance, consider increasing `worker_connections` value: https://nginx.org/en/docs/ngx_core_module.html#worker_connections. 178If you plan to have many concurrent viewers on your PeerTube instance, consider increasing `worker_connections` value: https://nginx.org/en/docs/ngx_core_module.html#worker_connections.
179 179
180**FreeBSD** 180<details>
181<summary><strong>If using FreeBSD</strong></summary>
182
181On FreeBSD you can use [Dehydrated](https://dehydrated.io/) `security/dehydrated` for [Let's Encrypt](https://letsencrypt.org/) 183On FreeBSD you can use [Dehydrated](https://dehydrated.io/) `security/dehydrated` for [Let's Encrypt](https://letsencrypt.org/)
182 184
183```bash 185```bash
184$ sudo pkg install dehydrated 186$ sudo pkg install dehydrated
185``` 187```
188</details>
186 189
187### :alembic: TCP/IP Tuning 190### :alembic: Linux TCP/IP Tuning
188
189**On Linux**
190 191
191```bash 192```bash
192$ sudo cp /var/www/peertube/peertube-latest/support/sysctl.d/30-peertube-tcp.conf /etc/sysctl.d/ 193$ sudo cp /var/www/peertube/peertube-latest/support/sysctl.d/30-peertube-tcp.conf /etc/sysctl.d/
@@ -231,7 +232,9 @@ $ sudo systemctl start peertube
231$ sudo journalctl -feu peertube 232$ sudo journalctl -feu peertube
232``` 233```
233 234
234**FreeBSD** 235<details>
236<summary><strong>If using FreeBSD</strong></summary>
237
235On FreeBSD, copy the startup script and update rc.conf: 238On FreeBSD, copy the startup script and update rc.conf:
236 239
237```bash 240```bash
@@ -244,8 +247,10 @@ Run:
244```bash 247```bash
245$ sudo service peertube start 248$ sudo service peertube start
246``` 249```
250</details>
247 251
248### :bricks: OpenRC 252<details>
253<summary><strong>If using OpenRC</strong></summary>
249 254
250If your OS uses OpenRC, copy the service script: 255If your OS uses OpenRC, copy the service script:
251 256
@@ -265,6 +270,7 @@ Run and print last logs:
265$ sudo /etc/init.d/peertube start 270$ sudo /etc/init.d/peertube start
266$ tail -f /var/log/peertube/peertube.log 271$ tail -f /var/log/peertube/peertube.log
267``` 272```
273</details>
268 274
269### :technologist: Administrator 275### :technologist: Administrator
270 276
@@ -291,16 +297,15 @@ Now your instance is up you can:
291 297
292**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md 298**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
293 299
294#### Auto 300Run the upgrade script (the password it asks is PeerTube's database user password):
295
296The password it asks is PeerTube's database user password.
297 301
298```bash 302```bash
299$ cd /var/www/peertube/peertube-latest/scripts && sudo -H -u peertube ./upgrade.sh 303$ cd /var/www/peertube/peertube-latest/scripts && sudo -H -u peertube ./upgrade.sh
300$ sudo systemctl restart peertube # Or use your OS command to restart PeerTube if you don't use systemd 304$ sudo systemctl restart peertube # Or use your OS command to restart PeerTube if you don't use systemd
301``` 305```
302 306
303#### Manually 307<details>
308<summary><strong>Prefer manual upgrade?</strong></summary>
304 309
305Make a SQL backup 310Make a SQL backup
306 311
@@ -346,17 +351,18 @@ $ cd /var/www/peertube && \
346 sudo unlink ./peertube-latest && \ 351 sudo unlink ./peertube-latest && \
347 sudo -u peertube ln -s versions/peertube-${VERSION} ./peertube-latest 352 sudo -u peertube ln -s versions/peertube-${VERSION} ./peertube-latest
348``` 353```
354</details>
349 355
350### Configuration 356### Update PeerTube configuration
351 357
352You can check for configuration changes, and report them in your `config/production.yaml` file: 358Check for configuration changes, and report them in your `config/production.yaml` file:
353 359
354```bash 360```bash
355$ cd /var/www/peertube/versions 361$ cd /var/www/peertube/versions
356$ diff -u "$(ls --sort=t | head -2 | tail -1)/config/production.yaml.example" "$(ls --sort=t | head -1)/config/production.yaml.example" 362$ diff -u "$(ls --sort=t | head -2 | tail -1)/config/production.yaml.example" "$(ls --sort=t | head -1)/config/production.yaml.example"
357``` 363```
358 364
359### nginx 365### Update nginx configuration
360 366
361Check changes in nginx configuration: 367Check changes in nginx configuration:
362 368
@@ -365,7 +371,7 @@ $ cd /var/www/peertube/versions
365$ diff -u "$(ls --sort=t | head -2 | tail -1)/support/nginx/peertube" "$(ls --sort=t | head -1)/support/nginx/peertube" 371$ diff -u "$(ls --sort=t | head -2 | tail -1)/support/nginx/peertube" "$(ls --sort=t | head -1)/support/nginx/peertube"
366``` 372```
367 373
368### systemd 374### Update systemd service
369 375
370Check changes in systemd configuration: 376Check changes in systemd configuration:
371 377
diff --git a/tsconfig.json b/tsconfig.json
index 993acf81d..8bcd944e3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,7 +8,6 @@
8 "@shared/*": [ "shared/*" ] 8 "@shared/*": [ "shared/*" ]
9 }, 9 },
10 "typeRoots": [ 10 "typeRoots": [
11 "server/typings",
12 "node_modules/@types" 11 "node_modules/@types"
13 ] 12 ]
14 }, 13 },
@@ -17,5 +16,5 @@
17 { "path": "./server" }, 16 { "path": "./server" },
18 { "path": "./scripts" } 17 { "path": "./scripts" }
19 ], 18 ],
20 "files": [ "server.ts", "server/types/express.d.ts" ] 19 "files": [ "server.ts", "server/types/express.d.ts", "server/types/lib.d.ts" ]
21} 20}
diff --git a/yarn.lock b/yarn.lock
index 4093a87fd..d9541b4d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9099,7 +9099,7 @@ typedarray@^0.0.6:
9099 resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" 9099 resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
9100 integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== 9100 integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
9101 9101
9102typescript@^4.0.5: 9102typescript@~4.8:
9103 version "4.8.4" 9103 version "4.8.4"
9104 resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" 9104 resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
9105 integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== 9105 integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==