]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Merge branch 'release/5.0.0' into develop
authorChocobozzz <me@florianbigard.com>
Mon, 23 Jan 2023 14:25:21 +0000 (15:25 +0100)
committerChocobozzz <me@florianbigard.com>
Mon, 23 Jan 2023 14:25:21 +0000 (15:25 +0100)
347 files changed:
.github/workflows/test.yml
client/.gitignore
client/e2e/src/po/admin-config.po.ts
client/e2e/src/po/admin-registration.po.ts [new file with mode: 0644]
client/e2e/src/po/login.po.ts
client/e2e/src/po/signup.po.ts
client/e2e/src/suites-local/signup.e2e-spec.ts
client/e2e/src/utils/elements.ts
client/e2e/src/utils/email.ts [new file with mode: 0644]
client/e2e/src/utils/hooks.ts
client/e2e/src/utils/index.ts
client/e2e/src/utils/mock-smtp.ts [new file with mode: 0644]
client/e2e/src/utils/server.ts
client/e2e/wdio.local-test.conf.ts
client/e2e/wdio.local.conf.ts
client/package.json
client/src/app/+about/about-instance/about-instance.component.html
client/src/app/+about/about-instance/about-instance.component.ts
client/src/app/+about/about-instance/about-instance.resolver.ts
client/src/app/+admin/admin.component.ts
client/src/app/+admin/admin.module.ts
client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html
client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html
client/src/app/+admin/follows/followers-list/followers-list.component.html
client/src/app/+admin/follows/followers-list/followers-list.component.ts
client/src/app/+admin/follows/following-list/following-list.component.html
client/src/app/+admin/follows/following-list/following-list.component.ts
client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
client/src/app/+admin/moderation/index.ts
client/src/app/+admin/moderation/moderation.routes.ts
client/src/app/+admin/moderation/registration-list/admin-registration.service.ts [new file with mode: 0644]
client/src/app/+admin/moderation/registration-list/index.ts [new file with mode: 0644]
client/src/app/+admin/moderation/registration-list/process-registration-modal.component.html [new file with mode: 0644]
client/src/app/+admin/moderation/registration-list/process-registration-modal.component.scss [new file with mode: 0644]
client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts [new file with mode: 0644]
client/src/app/+admin/moderation/registration-list/process-registration-validators.ts [new file with mode: 0644]
client/src/app/+admin/moderation/registration-list/registration-list.component.html [new file with mode: 0644]
client/src/app/+admin/moderation/registration-list/registration-list.component.scss [new file with mode: 0644]
client/src/app/+admin/moderation/registration-list/registration-list.component.ts [new file with mode: 0644]
client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts
client/src/app/+admin/overview/comments/video-comment-list.component.html
client/src/app/+admin/overview/comments/video-comment-list.component.ts
client/src/app/+admin/overview/users/user-list/user-list.component.html
client/src/app/+admin/overview/users/user-list/user-list.component.scss
client/src/app/+admin/overview/users/user-list/user-list.component.ts
client/src/app/+admin/overview/videos/video-list.component.html
client/src/app/+admin/overview/videos/video-list.component.ts
client/src/app/+admin/shared/shared-admin.module.ts
client/src/app/+admin/shared/user-email-info.component.html [new file with mode: 0644]
client/src/app/+admin/shared/user-email-info.component.scss [new file with mode: 0644]
client/src/app/+admin/shared/user-email-info.component.ts [new file with mode: 0644]
client/src/app/+admin/system/jobs/job.service.ts
client/src/app/+admin/system/jobs/jobs.component.ts
client/src/app/+login/login.component.ts
client/src/app/+my-library/my-ownership/my-ownership.component.scss
client/src/app/+my-library/my-ownership/my-ownership.component.ts
client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts
client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
client/src/app/+signup/+register/register.component.html
client/src/app/+signup/+register/register.component.ts
client/src/app/+signup/+register/shared/index.ts [new file with mode: 0644]
client/src/app/+signup/+register/shared/register-validators.ts [new file with mode: 0644]
client/src/app/+signup/+register/steps/register-step-about.component.html
client/src/app/+signup/+register/steps/register-step-about.component.ts
client/src/app/+signup/+register/steps/register-step-channel.component.ts
client/src/app/+signup/+register/steps/register-step-terms.component.html
client/src/app/+signup/+register/steps/register-step-terms.component.ts
client/src/app/+signup/+register/steps/register-step-user.component.ts
client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html
client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
client/src/app/+signup/shared/shared-signup.module.ts
client/src/app/+signup/shared/signup-success-after-email.component.html [new file with mode: 0644]
client/src/app/+signup/shared/signup-success-after-email.component.ts [new file with mode: 0644]
client/src/app/+signup/shared/signup-success-before-email.component.html [new file with mode: 0644]
client/src/app/+signup/shared/signup-success-before-email.component.ts [new file with mode: 0644]
client/src/app/+signup/shared/signup-success.component.html [deleted file]
client/src/app/+signup/shared/signup-success.component.ts [deleted file]
client/src/app/+signup/shared/signup.service.ts [moved from client/src/app/shared/shared-users/user-signup.service.ts with 53% similarity]
client/src/app/+videos/+video-watch/video-watch.component.ts
client/src/app/+videos/video-list/videos-list-common-page.component.ts
client/src/app/core/auth/auth.service.ts
client/src/app/core/renderer/linkifier.service.ts
client/src/app/core/renderer/markdown.service.ts
client/src/app/core/rest/rest-extractor.service.ts
client/src/app/core/rest/rest-table.ts
client/src/app/menu/menu.component.html
client/src/app/menu/menu.component.ts
client/src/app/shared/form-validators/form-validator.model.ts
client/src/app/shared/form-validators/user-validators.ts
client/src/app/shared/shared-abuse-list/abuse-details.component.html
client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts
client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts
client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts
client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts
client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts
client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts
client/src/app/shared/shared-forms/markdown-textarea.component.ts
client/src/app/shared/shared-instance/instance-features-table.component.html
client/src/app/shared/shared-instance/instance-features-table.component.ts
client/src/app/shared/shared-instance/instance.service.ts
client/src/app/shared/shared-main/account/index.ts
client/src/app/shared/shared-main/account/signup-label.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/account/signup-label.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/shared-main.module.ts
client/src/app/shared/shared-main/users/user-notification.model.ts
client/src/app/shared/shared-main/users/user-notifications.component.html
client/src/app/shared/shared-moderation/account-blocklist.component.scss
client/src/app/shared/shared-moderation/account-blocklist.component.ts
client/src/app/shared/shared-moderation/moderation.scss
client/src/app/shared/shared-moderation/server-blocklist.component.scss
client/src/app/shared/shared-moderation/server-blocklist.component.ts
client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
client/src/app/shared/shared-users/index.ts
client/src/app/shared/shared-users/shared-users.module.ts
client/src/app/shared/shared-users/user-admin.service.ts
client/src/app/shared/shared-video-miniature/video-miniature.component.html
client/src/app/shared/shared-video-miniature/video-miniature.component.scss
client/src/app/shared/shared-video-miniature/video-miniature.component.ts
client/src/app/shared/shared-video-miniature/videos-list.component.ts
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
client/src/app/shared/shared-video-playlist/video-playlist.service.ts
client/src/assets/player/peertube-player-manager.ts
client/src/assets/player/shared/control-bar/index.ts
client/src/assets/player/shared/control-bar/peertube-live-display.ts [new file with mode: 0644]
client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
client/src/assets/player/shared/stats/stats-card.ts
client/src/assets/player/types/manager-options.ts
client/src/assets/player/types/peertube-videojs-typings.ts
client/src/root-helpers/logger.ts
client/src/root-helpers/plugins-manager.ts
client/src/sass/class-helpers.scss
client/src/sass/include/_badges.scss
client/src/sass/include/_fonts.scss
client/src/sass/include/_mixins.scss
client/src/sass/player/control-bar.scss
client/src/sass/primeng-custom.scss
client/src/standalone/videos/shared/player-manager-options.ts
client/yarn.lock
config/default.yaml
config/dev.yaml
config/production.yaml.example
config/test.yaml
package.json
scripts/i18n/create-custom-files.ts
server.ts
server/controllers/activitypub/client.ts
server/controllers/api/config.ts
server/controllers/api/users/email-verification.ts [new file with mode: 0644]
server/controllers/api/users/index.ts
server/controllers/api/users/registrations.ts [new file with mode: 0644]
server/controllers/api/video-playlist.ts
server/controllers/api/videos/comment.ts
server/controllers/api/videos/token.ts
server/controllers/feeds.ts
server/controllers/tracker.ts
server/helpers/custom-validators/misc.ts
server/helpers/custom-validators/user-registration.ts [new file with mode: 0644]
server/helpers/custom-validators/video-captions.ts
server/helpers/custom-validators/video-imports.ts
server/helpers/decache.ts
server/helpers/memoize.ts [new file with mode: 0644]
server/helpers/youtube-dl/youtube-dl-cli.ts
server/initializers/checker-after-init.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/initializers/database.ts
server/initializers/installer.ts
server/initializers/migrations/0750-user-registration.ts [new file with mode: 0644]
server/initializers/migrations/0755-unique-viewer-url.ts [new file with mode: 0644]
server/lib/auth/external-auth.ts
server/lib/auth/oauth-model.ts
server/lib/auth/oauth.ts
server/lib/auth/tokens-cache.ts
server/lib/emailer.ts
server/lib/emails/common/base.pug
server/lib/emails/user-registration-request-accepted/html.pug [new file with mode: 0644]
server/lib/emails/user-registration-request-rejected/html.pug [new file with mode: 0644]
server/lib/emails/user-registration-request/html.pug [new file with mode: 0644]
server/lib/emails/verify-email/html.pug
server/lib/job-queue/job-queue.ts
server/lib/notifier/notifier.ts
server/lib/notifier/shared/instance/direct-registration-for-moderators.ts [moved from server/lib/notifier/shared/instance/registration-for-moderators.ts with 90% similarity]
server/lib/notifier/shared/instance/index.ts
server/lib/notifier/shared/instance/registration-request-for-moderators.ts [new file with mode: 0644]
server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts [new file with mode: 0644]
server/lib/opentelemetry/metric-helpers/index.ts
server/lib/opentelemetry/metrics.ts
server/lib/plugins/plugin-helpers-builder.ts
server/lib/redis.ts
server/lib/server-config-manager.ts
server/lib/signup.ts
server/lib/sync-channel.ts
server/lib/user.ts
server/lib/video-comment.ts
server/lib/video-tokens-manager.ts
server/middlewares/sort.ts
server/middlewares/validators/config.ts
server/middlewares/validators/index.ts
server/middlewares/validators/shared/user-registrations.ts [new file with mode: 0644]
server/middlewares/validators/shared/users.ts
server/middlewares/validators/shared/videos.ts
server/middlewares/validators/sort.ts
server/middlewares/validators/user-email-verification.ts [new file with mode: 0644]
server/middlewares/validators/user-registrations.ts [new file with mode: 0644]
server/middlewares/validators/users.ts
server/models/abuse/abuse-message.ts
server/models/abuse/abuse.ts
server/models/abuse/sql/abuse-query-builder.ts [moved from server/models/abuse/abuse-query-builder.ts with 97% similarity]
server/models/account/account-blocklist.ts
server/models/account/account-video-rate.ts
server/models/account/account.ts
server/models/actor/actor-follow.ts
server/models/actor/actor-image.ts
server/models/actor/actor.ts
server/models/actor/sql/instance-list-followers-query-builder.ts
server/models/actor/sql/instance-list-following-query-builder.ts
server/models/actor/sql/shared/actor-follow-table-attributes.ts
server/models/actor/sql/shared/instance-list-follows-query-builder.ts
server/models/redundancy/video-redundancy.ts
server/models/server/plugin.ts
server/models/server/server-blocklist.ts
server/models/server/server.ts
server/models/shared/index.ts
server/models/shared/model-builder.ts
server/models/shared/model-cache.ts [moved from server/models/model-cache.ts with 100% similarity]
server/models/shared/query.ts
server/models/shared/sequelize-helpers.ts [new file with mode: 0644]
server/models/shared/sort.ts [new file with mode: 0644]
server/models/shared/sql.ts [new file with mode: 0644]
server/models/shared/update.ts
server/models/user/sql/user-notitication-list-query-builder.ts
server/models/user/user-notification-setting.ts
server/models/user/user-notification.ts
server/models/user/user-registration.ts [new file with mode: 0644]
server/models/user/user.ts
server/models/utils.ts [deleted file]
server/models/video/formatter/video-format-utils.ts
server/models/video/sql/comment/video-comment-list-query-builder.ts [new file with mode: 0644]
server/models/video/sql/comment/video-comment-table-attributes.ts [new file with mode: 0644]
server/models/video/sql/video/shared/abstract-video-query-builder.ts
server/models/video/sql/video/videos-id-list-query-builder.ts
server/models/video/tag.ts
server/models/video/video-blacklist.ts
server/models/video/video-caption.ts
server/models/video/video-change-ownership.ts
server/models/video/video-channel-sync.ts
server/models/video/video-channel.ts
server/models/video/video-comment.ts
server/models/video/video-file.ts
server/models/video/video-import.ts
server/models/video/video-playlist-element.ts
server/models/video/video-playlist.ts
server/models/video/video-share.ts
server/models/video/video-streaming-playlist.ts
server/models/video/video.ts
server/models/view/local-video-viewer.ts
server/tests/api/activitypub/cleaner.ts
server/tests/api/check-params/config.ts
server/tests/api/check-params/contact-form.ts
server/tests/api/check-params/index.ts
server/tests/api/check-params/redundancy.ts
server/tests/api/check-params/registrations.ts [new file with mode: 0644]
server/tests/api/check-params/upload-quota.ts
server/tests/api/check-params/users-admin.ts
server/tests/api/check-params/users-emails.ts [new file with mode: 0644]
server/tests/api/check-params/users.ts [deleted file]
server/tests/api/live/live-fast-restream.ts
server/tests/api/notifications/index.ts
server/tests/api/notifications/moderation-notifications.ts
server/tests/api/notifications/registrations-notifications.ts [new file with mode: 0644]
server/tests/api/object-storage/video-static-file-privacy.ts
server/tests/api/server/config-defaults.ts
server/tests/api/server/config.ts
server/tests/api/server/contact-form.ts
server/tests/api/server/email.ts
server/tests/api/server/reverse-proxy.ts
server/tests/api/users/index.ts
server/tests/api/users/oauth.ts [new file with mode: 0644]
server/tests/api/users/registrations.ts [new file with mode: 0644]
server/tests/api/users/users-email-verification.ts [moved from server/tests/api/users/users-verification.ts with 86% similarity]
server/tests/api/users/users.ts
server/tests/api/videos/video-channel-syncs.ts
server/tests/api/videos/video-comments.ts
server/tests/api/videos/video-imports.ts
server/tests/api/videos/video-playlists.ts
server/tests/external-plugins/akismet.ts
server/tests/external-plugins/auto-block-videos.ts
server/tests/external-plugins/auto-mute.ts
server/tests/feeds/feeds.ts
server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
server/tests/fixtures/peertube-plugin-test-four/main.js
server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
server/tests/fixtures/peertube-plugin-test/main.js
server/tests/helpers/index.ts
server/tests/helpers/version.ts [new file with mode: 0644]
server/tests/plugins/action-hooks.ts
server/tests/plugins/external-auth.ts
server/tests/plugins/filter-hooks.ts
server/tests/plugins/id-and-pass-auth.ts
server/tests/plugins/plugin-helpers.ts
server/tests/shared/notifications.ts
server/tests/shared/videos.ts
server/types/express.d.ts
server/types/lib.d.ts [new file with mode: 0644]
server/types/models/user/index.ts
server/types/models/user/user-notification.ts
server/types/models/user/user-registration.ts [new file with mode: 0644]
server/types/plugins/register-server-auth.model.ts
server/types/plugins/register-server-option.model.ts
shared/core-utils/common/version.ts
shared/core-utils/plugins/hooks.ts
shared/core-utils/renderer/html.ts
shared/core-utils/users/user-role.ts
shared/models/plugins/server/server-hook.model.ts
shared/models/server/custom-config.model.ts
shared/models/server/server-config.model.ts
shared/models/server/server-error-code.enum.ts
shared/models/users/index.ts
shared/models/users/registration/index.ts [new file with mode: 0644]
shared/models/users/registration/user-register.model.ts [moved from shared/models/users/user-register.model.ts with 100% similarity]
shared/models/users/registration/user-registration-request.model.ts [new file with mode: 0644]
shared/models/users/registration/user-registration-state.model.ts [new file with mode: 0644]
shared/models/users/registration/user-registration-update-state.model.ts [new file with mode: 0644]
shared/models/users/registration/user-registration.model.ts [new file with mode: 0644]
shared/models/users/user-notification.model.ts
shared/models/users/user-right.enum.ts
shared/server-commands/miscs/sql-command.ts
shared/server-commands/requests/requests.ts
shared/server-commands/server/config-command.ts
shared/server-commands/server/server.ts
shared/server-commands/users/index.ts
shared/server-commands/users/registrations-command.ts [new file with mode: 0644]
shared/server-commands/users/users-command.ts
support/doc/api/openapi.yaml
support/doc/dependencies.md
support/doc/docker.md
support/doc/plugins/guide.md
support/doc/production.md
tsconfig.json
yarn.lock

index 65e1acec60293ab594dddf849a661d14463d5d14..678b0674bc24c3cd4396817d9a7be3812cc1b54d 100644 (file)
@@ -48,6 +48,7 @@ jobs:
       ENABLE_OBJECT_STORAGE_TESTS: true
       OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }}
       OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }}
+      YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
     steps:
       - uses: actions/checkout@v3
index 3241b09ed7c34a1b6329227d22c2111a902ee0e1..ca68413c85be9c8450c10b79887dba3d45f52a45 100644 (file)
@@ -11,5 +11,6 @@
 /src/locale/target/server_*.xml
 /e2e/local.log
 /e2e/browserstack.err
+/e2e/screenshots
 /src/standalone/player/build
 /src/standalone/player/dist
index 27957a71f1dd9b3de3272acd2675c9863598873a..510037ddd7103b80872759896b0433dece5969be 100644 (file)
@@ -1,4 +1,4 @@
-import { getCheckbox, go } from '../utils'
+import { browserSleep, getCheckbox, go, isCheckboxSelected } from '../utils'
 
 export class AdminConfigPage {
 
@@ -8,7 +8,6 @@ export class AdminConfigPage {
       'basic-configuration': 'APPEARANCE',
       'instance-information': 'INSTANCE'
     }
-
     await go('/admin/config/edit-custom#' + tab)
 
     await $('.inner-form-title=' + waitTitles[tab]).waitForDisplayed()
@@ -28,17 +27,39 @@ export class AdminConfigPage {
     return $('#instanceCustomHomepageContent').setValue(newValue)
   }
 
-  async toggleSignup () {
+  async toggleSignup (enabled: boolean) {
+    if (await isCheckboxSelected('signupEnabled') === enabled) return
+
     const checkbox = await getCheckbox('signupEnabled')
 
     await checkbox.waitForClickable()
     await checkbox.click()
   }
 
+  async toggleSignupApproval (required: boolean) {
+    if (await isCheckboxSelected('signupRequiresApproval') === required) return
+
+    const checkbox = await getCheckbox('signupRequiresApproval')
+
+    await checkbox.waitForClickable()
+    await checkbox.click()
+  }
+
+  async toggleSignupEmailVerification (required: boolean) {
+    if (await isCheckboxSelected('signupRequiresEmailVerification') === required) return
+
+    const checkbox = await getCheckbox('signupRequiresEmailVerification')
+
+    await checkbox.waitForClickable()
+    await checkbox.click()
+  }
+
   async save () {
     const button = $('input[type=submit]')
 
     await button.waitForClickable()
     await button.click()
+
+    await browserSleep(1000)
   }
 }
diff --git a/client/e2e/src/po/admin-registration.po.ts b/client/e2e/src/po/admin-registration.po.ts
new file mode 100644 (file)
index 0000000..8523465
--- /dev/null
@@ -0,0 +1,35 @@
+import { browserSleep, findParentElement, go } from '../utils'
+
+export class AdminRegistrationPage {
+
+  async navigateToRegistratonsList () {
+    await go('/admin/moderation/registrations/list')
+
+    await $('my-registration-list').waitForDisplayed()
+  }
+
+  async accept (username: string, moderationResponse: string) {
+    const usernameEl = await $('*=' + username)
+    await usernameEl.waitForDisplayed()
+
+    const tr = await findParentElement(usernameEl, async el => await el.getTagName() === 'tr')
+
+    await tr.$('.action-cell .dropdown-root').click()
+
+    const accept = await $('span*=Accept this registration')
+    await accept.waitForClickable()
+    await accept.click()
+
+    const moderationResponseTextarea = await $('#moderationResponse')
+    await moderationResponseTextarea.waitForDisplayed()
+
+    await moderationResponseTextarea.setValue(moderationResponse)
+
+    const submitButton = $('.modal-footer input[type=submit]')
+    await submitButton.waitForClickable()
+    await submitButton.click()
+
+    await browserSleep(1000)
+  }
+
+}
index bc1854dbc17012fcae2ba5b951784ff7828a1e0d..f1d13a2b0ce23ad748faf742cd89c9740f85aa3f 100644 (file)
@@ -6,7 +6,14 @@ export class LoginPage {
 
   }
 
-  async login (username: string, password: string, url = '/login') {
+  async login (options: {
+    username: string
+    password: string
+    displayName?: string
+    url?: string
+  }) {
+    const { username, password, url = '/login', displayName = username } = options
+
     await go(url)
 
     await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`)
@@ -27,27 +34,40 @@ export class LoginPage {
 
       await menuToggle.click()
 
-      await this.ensureIsLoggedInAs(username)
+      await this.ensureIsLoggedInAs(displayName)
 
       await menuToggle.click()
     } else {
-      await this.ensureIsLoggedInAs(username)
+      await this.ensureIsLoggedInAs(displayName)
     }
   }
 
+  async getLoginError (username: string, password: string) {
+    await go('/login')
+
+    await $('input#username').setValue(username)
+    await $('input#password').setValue(password)
+
+    await browser.pause(1000)
+
+    await $('form input[type=submit]').click()
+
+    return $('.alert-danger').getText()
+  }
+
   async loginAsRootUser () {
-    return this.login('root', 'test' + this.getSuffix())
+    return this.login({ username: 'root', password: 'test' + this.getSuffix() })
   }
 
   loginOnPeerTube2 () {
-    return this.login('e2e', process.env.PEERTUBE2_E2E_PASSWORD, 'https://peertube2.cpy.re/login')
+    return this.login({ username: 'e2e', password: process.env.PEERTUBE2_E2E_PASSWORD, url: 'https://peertube2.cpy.re/login' })
   }
 
   async logout () {
-    const loggedInMore = $('.logged-in-more')
+    const loggedInDropdown = $('.logged-in-more .logged-in-info')
 
-    await loggedInMore.waitForClickable()
-    await loggedInMore.click()
+    await loggedInDropdown.waitForClickable()
+    await loggedInDropdown.click()
 
     const logout = $('.dropdown-item*=Log out')
 
index cc2ed7c5f6fe7a0a0781a74ae1e2fa3b8074cb99..7917cdda7836b339fc8bc74df69d5aedfd57ffc2 100644 (file)
@@ -27,42 +27,39 @@ export class SignupPage {
     return terms.click()
   }
 
+  async getEndMessage () {
+    const alert = $('.pt-alert-primary')
+    await alert.waitForDisplayed()
+
+    return alert.getText()
+  }
+
+  async fillRegistrationReason (reason: string) {
+    await $('#registrationReason').setValue(reason)
+  }
+
   async fillAccountStep (options: {
-    displayName: string
     username: string
-    email: string
-    password: string
+    password?: string
+    displayName?: string
+    email?: string
   }) {
-    if (options.displayName) {
-      await $('#displayName').setValue(options.displayName)
-    }
-
-    if (options.username) {
-      await $('#username').setValue(options.username)
-    }
+    await $('#displayName').setValue(options.displayName || `${options.username} display name`)
 
-    if (options.email) {
-      // Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
-      await $('#email').scrollIntoView(false)
-      await $('#email').waitForClickable()
-      await $('#email').setValue(options.email)
-    }
+    await $('#username').setValue(options.username)
+    await $('#password').setValue(options.password || 'password')
 
-    if (options.password) {
-      await $('#password').setValue(options.password)
-    }
+    // Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
+    await $('#email').scrollIntoView(false)
+    await $('#email').waitForClickable()
+    await $('#email').setValue(options.email || `${options.username}@example.com`)
   }
 
   async fillChannelStep (options: {
-    displayName: string
     name: string
+    displayName?: string
   }) {
-    if (options.displayName) {
-      await $('#displayName').setValue(options.displayName)
-    }
-
-    if (options.name) {
-      await $('#name').setValue(options.name)
-    }
+    await $('#displayName').setValue(options.displayName || `${options.name} channel display name`)
+    await $('#name').setValue(options.name)
   }
 }
index 4eed3eefe0977e3accca6e04e68e23b395e593e3..b6f7ad1a788bd272ca8ea5b77d783d1104f6832f 100644 (file)
@@ -1,12 +1,89 @@
 import { AdminConfigPage } from '../po/admin-config.po'
+import { AdminRegistrationPage } from '../po/admin-registration.po'
 import { LoginPage } from '../po/login.po'
 import { SignupPage } from '../po/signup.po'
-import { isMobileDevice, waitServerUp } from '../utils'
+import { browserSleep, getVerificationLink, go, findEmailTo, isMobileDevice, MockSMTPServer, waitServerUp } from '../utils'
+
+function checkEndMessage (options: {
+  message: string
+  requiresEmailVerification: boolean
+  requiresApproval: boolean
+  afterEmailVerification: boolean
+}) {
+  const { message, requiresApproval, requiresEmailVerification, afterEmailVerification } = options
+
+  {
+    const created = 'account has been created'
+    const request = 'account request has been sent'
+
+    if (requiresApproval) {
+      expect(message).toContain(request)
+      expect(message).not.toContain(created)
+    } else {
+      expect(message).not.toContain(request)
+      expect(message).toContain(created)
+    }
+  }
+
+  {
+    const checkEmail = 'Check your emails'
+
+    if (requiresEmailVerification) {
+      expect(message).toContain(checkEmail)
+    } else {
+      expect(message).not.toContain(checkEmail)
+
+      const moderatorsApproval = 'moderator will check your registration request'
+      if (requiresApproval) {
+        expect(message).toContain(moderatorsApproval)
+      } else {
+        expect(message).not.toContain(moderatorsApproval)
+      }
+    }
+  }
+
+  {
+    const emailVerified = 'email has been verified'
+
+    if (afterEmailVerification) {
+      expect(message).toContain(emailVerified)
+    } else {
+      expect(message).not.toContain(emailVerified)
+    }
+  }
+}
 
 describe('Signup', () => {
   let loginPage: LoginPage
   let adminConfigPage: AdminConfigPage
   let signupPage: SignupPage
+  let adminRegistrationPage: AdminRegistrationPage
+
+  async function prepareSignup (options: {
+    enabled: boolean
+    requiresApproval?: boolean
+    requiresEmailVerification?: boolean
+  }) {
+    await loginPage.loginAsRootUser()
+
+    await adminConfigPage.navigateTo('basic-configuration')
+    await adminConfigPage.toggleSignup(options.enabled)
+
+    if (options.enabled) {
+      if (options.requiresApproval !== undefined) {
+        await adminConfigPage.toggleSignupApproval(options.requiresApproval)
+      }
+
+      if (options.requiresEmailVerification !== undefined) {
+        await adminConfigPage.toggleSignupEmailVerification(options.requiresEmailVerification)
+      }
+    }
+
+    await adminConfigPage.save()
+
+    await loginPage.logout()
+    await browser.refresh()
+  }
 
   before(async () => {
     await waitServerUp()
@@ -16,72 +93,310 @@ describe('Signup', () => {
     loginPage = new LoginPage(isMobileDevice())
     adminConfigPage = new AdminConfigPage()
     signupPage = new SignupPage()
+    adminRegistrationPage = new AdminRegistrationPage()
 
     await browser.maximizeWindow()
   })
 
-  it('Should disable signup', async () => {
-    await loginPage.loginAsRootUser()
+  describe('Signup disabled', function () {
+    it('Should disable signup', async () => {
+      await prepareSignup({ enabled: false })
 
-    await adminConfigPage.navigateTo('basic-configuration')
-    await adminConfigPage.toggleSignup()
+      await expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
+    })
+  })
 
-    await adminConfigPage.save()
+  describe('Email verification disabled', function () {
 
-    await loginPage.logout()
-    await browser.refresh()
+    describe('Direct registration', function () {
 
-    expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
-  })
+      it('Should enable signup without approval', async () => {
+        await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: false })
 
-  it('Should enable signup', async () => {
-    await loginPage.loginAsRootUser()
+        await signupPage.getRegisterMenuButton().waitForDisplayed()
+      })
 
-    await adminConfigPage.navigateTo('basic-configuration')
-    await adminConfigPage.toggleSignup()
+      it('Should go on signup page', async function () {
+        await signupPage.clickOnRegisterInMenu()
+      })
 
-    await adminConfigPage.save()
+      it('Should validate the first step (about page)', async function () {
+        await signupPage.validateStep()
+      })
 
-    await loginPage.logout()
-    await browser.refresh()
+      it('Should validate the second step (terms)', async function () {
+        await signupPage.checkTerms()
+        await signupPage.validateStep()
+      })
 
-    expect(signupPage.getRegisterMenuButton()).toBeDisplayed()
-  })
+      it('Should validate the third step (account)', async function () {
+        await signupPage.fillAccountStep({ username: 'user_1', displayName: 'user_1_dn' })
 
-  it('Should go on signup page', async function () {
-    await signupPage.clickOnRegisterInMenu()
-  })
+        await signupPage.validateStep()
+      })
 
-  it('Should validate the first step (about page)', async function () {
-    await signupPage.validateStep()
-  })
+      it('Should validate the third step (channel)', async function () {
+        await signupPage.fillChannelStep({ name: 'user_1_channel' })
 
-  it('Should validate the second step (terms)', async function () {
-    await signupPage.checkTerms()
-    await signupPage.validateStep()
-  })
+        await signupPage.validateStep()
+      })
+
+      it('Should be logged in', async function () {
+        await loginPage.ensureIsLoggedInAs('user_1_dn')
+      })
+
+      it('Should have a valid end message', async function () {
+        const message = await signupPage.getEndMessage()
+
+        checkEndMessage({
+          message,
+          requiresEmailVerification: false,
+          requiresApproval: false,
+          afterEmailVerification: false
+        })
 
-  it('Should validate the third step (account)', async function () {
-    await signupPage.fillAccountStep({
-      displayName: 'user 1',
-      username: 'user_1',
-      email: 'user_1@example.com',
-      password: 'my_super_password'
+        await browser.saveScreenshot('./screenshots/direct-without-email.png')
+
+        await loginPage.logout()
+      })
     })
 
-    await signupPage.validateStep()
+    describe('Registration with approval', function () {
+
+      it('Should enable signup with approval', async () => {
+        await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: false })
+
+        await signupPage.getRegisterMenuButton().waitForDisplayed()
+      })
+
+      it('Should go on signup page', async function () {
+        await signupPage.clickOnRegisterInMenu()
+      })
+
+      it('Should validate the first step (about page)', async function () {
+        await signupPage.validateStep()
+      })
+
+      it('Should validate the second step (terms)', async function () {
+        await signupPage.checkTerms()
+        await signupPage.fillRegistrationReason('my super reason')
+        await signupPage.validateStep()
+      })
+
+      it('Should validate the third step (account)', async function () {
+        await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'password' })
+        await signupPage.validateStep()
+      })
+
+      it('Should validate the third step (channel)', async function () {
+        await signupPage.fillChannelStep({ name: 'user_2_channel' })
+        await signupPage.validateStep()
+      })
+
+      it('Should have a valid end message', async function () {
+        const message = await signupPage.getEndMessage()
+
+        checkEndMessage({
+          message,
+          requiresEmailVerification: false,
+          requiresApproval: true,
+          afterEmailVerification: false
+        })
+
+        await browser.saveScreenshot('./screenshots/request-without-email.png')
+      })
+
+      it('Should display a message when trying to login with this account', async function () {
+        const error = await loginPage.getLoginError('user_2', 'password')
+
+        expect(error).toContain('awaiting approval')
+      })
+
+      it('Should accept the registration', async function () {
+        await loginPage.loginAsRootUser()
+
+        await adminRegistrationPage.navigateToRegistratonsList()
+        await adminRegistrationPage.accept('user_2', 'moderation response')
+
+        await loginPage.logout()
+      })
+
+      it('Should be able to login with this new account', async function () {
+        await loginPage.login({ username: 'user_2', password: 'password', displayName: 'user_2 display name' })
+
+        await loginPage.logout()
+      })
+    })
   })
 
-  it('Should validate the third step (channel)', async function () {
-    await signupPage.fillChannelStep({
-      displayName: 'user 1 channel',
-      name: 'user_1_channel'
+  describe('Email verification enabled', function () {
+    const emails: any[] = []
+    let emailPort: number
+
+    before(async () => {
+      // FIXME: typings are wrong, get returns a promise
+      emailPort = await browser.sharedStore.get('emailPort') as unknown as number
+
+      MockSMTPServer.Instance.collectEmails(emailPort, emails)
     })
 
-    await signupPage.validateStep()
-  })
+    describe('Direct registration', function () {
+
+      it('Should enable signup without approval', async () => {
+        await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: true })
+
+        await signupPage.getRegisterMenuButton().waitForDisplayed()
+      })
+
+      it('Should go on signup page', async function () {
+        await signupPage.clickOnRegisterInMenu()
+      })
+
+      it('Should validate the first step (about page)', async function () {
+        await signupPage.validateStep()
+      })
+
+      it('Should validate the second step (terms)', async function () {
+        await signupPage.checkTerms()
+        await signupPage.validateStep()
+      })
+
+      it('Should validate the third step (account)', async function () {
+        await signupPage.fillAccountStep({ username: 'user_3', displayName: 'user_3 display name', email: 'user_3@example.com' })
+
+        await signupPage.validateStep()
+      })
+
+      it('Should validate the third step (channel)', async function () {
+        await signupPage.fillChannelStep({ name: 'user_3_channel' })
+
+        await signupPage.validateStep()
+      })
+
+      it('Should have a valid end message', async function () {
+        const message = await signupPage.getEndMessage()
+
+        checkEndMessage({
+          message,
+          requiresEmailVerification: true,
+          requiresApproval: false,
+          afterEmailVerification: false
+        })
+
+        await browser.saveScreenshot('./screenshots/direct-with-email.png')
+      })
+
+      it('Should validate the email', async function () {
+        let email: { text: string }
+
+        while (!(email = findEmailTo(emails, 'user_3@example.com'))) {
+          await browserSleep(100)
+        }
+
+        await go(getVerificationLink(email))
+
+        const message = await signupPage.getEndMessage()
+
+        checkEndMessage({
+          message,
+          requiresEmailVerification: false,
+          requiresApproval: false,
+          afterEmailVerification: true
+        })
 
-  it('Should be logged in', async function () {
-    await loginPage.ensureIsLoggedInAs('user 1')
+        await browser.saveScreenshot('./screenshots/direct-after-email.png')
+      })
+    })
+
+    describe('Registration with approval', function () {
+
+      it('Should enable signup without approval', async () => {
+        await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: true })
+
+        await signupPage.getRegisterMenuButton().waitForDisplayed()
+      })
+
+      it('Should go on signup page', async function () {
+        await signupPage.clickOnRegisterInMenu()
+      })
+
+      it('Should validate the first step (about page)', async function () {
+        await signupPage.validateStep()
+      })
+
+      it('Should validate the second step (terms)', async function () {
+        await signupPage.checkTerms()
+        await signupPage.fillRegistrationReason('my super reason 2')
+        await signupPage.validateStep()
+      })
+
+      it('Should validate the third step (account)', async function () {
+        await signupPage.fillAccountStep({
+          username: 'user_4',
+          displayName: 'user_4 display name',
+          email: 'user_4@example.com',
+          password: 'password'
+        })
+        await signupPage.validateStep()
+      })
+
+      it('Should validate the third step (channel)', async function () {
+        await signupPage.fillChannelStep({ name: 'user_4_channel' })
+        await signupPage.validateStep()
+      })
+
+      it('Should have a valid end message', async function () {
+        const message = await signupPage.getEndMessage()
+
+        checkEndMessage({
+          message,
+          requiresEmailVerification: true,
+          requiresApproval: true,
+          afterEmailVerification: false
+        })
+
+        await browser.saveScreenshot('./screenshots/request-with-email.png')
+      })
+
+      it('Should display a message when trying to login with this account', async function () {
+        const error = await loginPage.getLoginError('user_4', 'password')
+
+        expect(error).toContain('awaiting approval')
+      })
+
+      it('Should accept the registration', async function () {
+        await loginPage.loginAsRootUser()
+
+        await adminRegistrationPage.navigateToRegistratonsList()
+        await adminRegistrationPage.accept('user_4', 'moderation response 2')
+
+        await loginPage.logout()
+      })
+
+      it('Should validate the email', async function () {
+        let email: { text: string }
+
+        while (!(email = findEmailTo(emails, 'user_4@example.com'))) {
+          await browserSleep(100)
+        }
+
+        await go(getVerificationLink(email))
+
+        const message = await signupPage.getEndMessage()
+
+        checkEndMessage({
+          message,
+          requiresEmailVerification: false,
+          requiresApproval: true,
+          afterEmailVerification: true
+        })
+
+        await browser.saveScreenshot('./screenshots/request-after-email.png')
+      })
+    })
+
+    before(() => {
+      MockSMTPServer.Instance.kill()
+    })
   })
 })
index b0ddd5a65e7bd6d7c6c1406e0c122bdd2a46d6ac..d9435e52052305eb6782542a8b08705c9f9a9462 100644 (file)
@@ -5,6 +5,10 @@ async function getCheckbox (name: string) {
   return input.parentElement()
 }
 
+function isCheckboxSelected (name: string) {
+  return $(`input[id=${name}]`).isSelected()
+}
+
 async function selectCustomSelect (id: string, valueLabel: string) {
   const wrapper = $(`[formcontrolname=${id}] .ng-arrow-wrapper`)
 
@@ -22,7 +26,18 @@ async function selectCustomSelect (id: string, valueLabel: string) {
   return option.click()
 }
 
+async function findParentElement (
+  el: WebdriverIO.Element,
+  finder: (el: WebdriverIO.Element) => Promise<boolean>
+) {
+  if (await finder(el) === true) return el
+
+  return findParentElement(await el.parentElement(), finder)
+}
+
 export {
   getCheckbox,
-  selectCustomSelect
+  isCheckboxSelected,
+  selectCustomSelect,
+  findParentElement
 }
diff --git a/client/e2e/src/utils/email.ts b/client/e2e/src/utils/email.ts
new file mode 100644 (file)
index 0000000..2ad1203
--- /dev/null
@@ -0,0 +1,31 @@
+function getVerificationLink (email: { text: string }) {
+  const { text } = email
+
+  const regexp = /\[(?<link>http:\/\/[^\]]+)\]/g
+  const matched = text.matchAll(regexp)
+
+  if (!matched) throw new Error('Could not find verification link in email')
+
+  for (const match of matched) {
+    const link = match.groups.link
+
+    if (link.includes('/verify-account/')) return link
+  }
+
+  throw new Error('Could not find /verify-account/ link')
+}
+
+function findEmailTo (emails: { text: string, to: { address: string }[] }[], to: string) {
+  for (const email of emails) {
+    for (const { address } of email.to) {
+      if (address === to) return email
+    }
+  }
+
+  return undefined
+}
+
+export {
+  getVerificationLink,
+  findEmailTo
+}
index 889cf1d86cee7cdfe1306760d2f94d8c5f29e1b1..7fe2476810fdbf0b289311e9814ff08fdd91ebb3 100644 (file)
@@ -1,10 +1,13 @@
 import { ChildProcessWithoutNullStreams } from 'child_process'
 import { basename } from 'path'
 import { runCommand, runServer } from './server'
+import { setValue } from '@wdio/shared-store-service'
 
-let appInstance: string
+let appInstance: number
 let app: ChildProcessWithoutNullStreams
 
+let emailPort: number
+
 async function beforeLocalSuite (suite: any) {
   const config = buildConfig(suite.file)
 
@@ -17,13 +20,20 @@ function afterLocalSuite () {
   app = undefined
 }
 
-function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
-  appInstance = capabilities['browserName'] === 'chrome' ? '1' : '2'
+async function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
+  appInstance = capabilities['browserName'] === 'chrome'
+    ? 1
+    : 2
+
+  emailPort = 1025 + appInstance
+
   config.baseUrl = 'http://localhost:900' + appInstance
+
+  await setValue('emailPort', emailPort)
 }
 
 async function onBrowserStackPrepare () {
-  const appInstance = '1'
+  const appInstance = 1
 
   await runCommand('npm run clean:server:test -- ' + appInstance)
   app = runServer(appInstance)
@@ -71,7 +81,11 @@ function buildConfig (suiteFile: string = undefined) {
   if (filename === 'signup.e2e-spec.ts') {
     return {
       signup: {
-        enabled: true
+        limit: -1
+      },
+      smtp: {
+        hostname: '127.0.0.1',
+        port: emailPort
       }
     }
   }
index 354352ee2e87e7a057a112537248ffc3550bab90..420fd239eb2e6fefe015e64b10727aa4625c26a4 100644 (file)
@@ -1,5 +1,7 @@
 export * from './common'
 export * from './elements'
+export * from './email'
 export * from './hooks'
+export * from './mock-smtp'
 export * from './server'
 export * from './urls'
diff --git a/client/e2e/src/utils/mock-smtp.ts b/client/e2e/src/utils/mock-smtp.ts
new file mode 100644 (file)
index 0000000..614477d
--- /dev/null
@@ -0,0 +1,58 @@
+import { ChildProcess } from 'child_process'
+import MailDev from '@peertube/maildev'
+
+class MockSMTPServer {
+
+  private static instance: MockSMTPServer
+  private started = false
+  private emailChildProcess: ChildProcess
+  private emails: object[]
+
+  collectEmails (port: number, emailsCollection: object[]) {
+    return new Promise<number>((res, rej) => {
+      this.emails = emailsCollection
+
+      if (this.started) {
+        return res(undefined)
+      }
+
+      const maildev = new MailDev({
+        ip: '127.0.0.1',
+        smtp: port,
+        disableWeb: true,
+        silent: true
+      })
+
+      maildev.on('new', email => {
+        this.emails.push(email)
+      })
+
+      maildev.listen(err => {
+        if (err) return rej(err)
+
+        this.started = true
+
+        return res(port)
+      })
+    })
+  }
+
+  kill () {
+    if (!this.emailChildProcess) return
+
+    process.kill(this.emailChildProcess.pid)
+
+    this.emailChildProcess = null
+    MockSMTPServer.instance = null
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  MockSMTPServer
+}
index 1400547943401d22ed91f69fd7ee83e6d3d97b79..227f4aea67ff90100277ac74c678d7a2e35fac39 100644 (file)
@@ -1,10 +1,10 @@
 import { exec, spawn } from 'child_process'
 import { join, resolve } from 'path'
 
-function runServer (appInstance: string, config: any = {}) {
+function runServer (appInstance: number, config: any = {}) {
   const env = Object.create(process.env)
   env['NODE_ENV'] = 'test'
-  env['NODE_APP_INSTANCE'] = appInstance
+  env['NODE_APP_INSTANCE'] = appInstance + ''
 
   env['NODE_CONFIG'] = JSON.stringify({
     rates_limit: {
index ca0bb5bfe32c5942f28570f3f5fe34ec600b333c..bc15123a03f8bb6b43ae4f4c5ddad243fce6eb27 100644 (file)
@@ -37,7 +37,7 @@ module.exports = {
       // }
     ],
 
-    services: [ 'chromedriver', 'geckodriver' ],
+    services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
 
     beforeSession: beforeLocalSession,
     beforeSuite: beforeLocalSuite,
index d02679e065787042a60f35447e0a19fe20dcd952..27c6e867bf10417afc57a94c3e5c2cc44328c1fc 100644 (file)
@@ -33,7 +33,7 @@ module.exports = {
       }
     ],
 
-    services: [ 'chromedriver', 'geckodriver' ],
+    services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
 
     beforeSession: beforeLocalSession,
     beforeSuite: beforeLocalSuite,
index 115a4a1997b4ede9cf2b3a8161f02bc27589d1f4..31d9b1e7ce6696d7d3ab4857ab9b94ebb7fea95c 100644 (file)
@@ -52,8 +52,9 @@
     "@ngx-loading-bar/core": "^6.0.0",
     "@ngx-loading-bar/http-client": "^6.0.0",
     "@ngx-loading-bar/router": "^6.0.0",
-    "@peertube/p2p-media-loader-core": "^1.0.13",
-    "@peertube/p2p-media-loader-hlsjs": "^1.0.13",
+    "@peertube/maildev": "^1.2.0",
+    "@peertube/p2p-media-loader-core": "^1.0.14",
+    "@peertube/p2p-media-loader-hlsjs": "^1.0.14",
     "@peertube/videojs-contextmenu": "^5.5.0",
     "@peertube/xliffmerge": "^2.0.3",
     "@popperjs/core": "^2.11.5",
@@ -75,6 +76,7 @@
     "@wdio/cli": "^7.25.2",
     "@wdio/local-runner": "^7.25.2",
     "@wdio/mocha-framework": "^7.25.2",
+    "@wdio/shared-store-service": "^7.25.2",
     "@wdio/spec-reporter": "^7.25.1",
     "angular2-hotkeys": "^13.1.0",
     "angularx-qrcode": "14.0.0",
index b113df82ff62ce6f6678052d9ae5aa41f9b0a8c5..fdd6157e5099944439de0f4f2e7ce982e899076e 100644 (file)
@@ -21,7 +21,7 @@
 
     <div class="anchor" id="administrators-and-sustainability"></div>
     <a
-      *ngIf="html.administrator || html.maintenanceLifetime || html.businessModel"
+      *ngIf="aboutHTML.administrator || aboutHTML.maintenanceLifetime || aboutHTML.businessModel"
       class="anchor-link"
       routerLink="/about/instance"
       fragment="administrators-and-sustainability"
@@ -33,7 +33,7 @@
       </h2>
     </a>
 
-    <div class="block administrator" *ngIf="html.administrator">
+    <div class="block administrator" *ngIf="aboutHTML.administrator">
       <div class="anchor" id="administrators"></div>
       <a
         class="anchor-link"
         <h3 i18n class="section-title">Who we are</h3>
       </a>
 
-      <div [innerHTML]="html.administrator"></div>
+      <div [innerHTML]="aboutHTML.administrator"></div>
     </div>
 
-    <div class="block creation-reason" *ngIf="html.creationReason">
+    <div class="block creation-reason" *ngIf="aboutHTML.creationReason">
       <div class="anchor" id="creation-reason"></div>
       <a
         class="anchor-link"
         <h3 i18n class="section-title">Why we created this instance</h3>
       </a>
 
-      <div [innerHTML]="html.creationReason"></div>
+      <div [innerHTML]="aboutHTML.creationReason"></div>
     </div>
 
-    <div class="block maintenance-lifetime" *ngIf="html.maintenanceLifetime">
+    <div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
       <div class="anchor" id="maintenance-lifetime"></div>
       <a
         class="anchor-link"
         <h3 i18n class="section-title">How long we plan to maintain this instance</h3>
       </a>
 
-      <div [innerHTML]="html.maintenanceLifetime"></div>
+      <div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
     </div>
 
-    <div class="block business-model" *ngIf="html.businessModel">
+    <div class="block business-model" *ngIf="aboutHTML.businessModel">
         <div class="anchor" id="business-model"></div>
         <a
           class="anchor-link"
           <h3 i18n class="section-title">How we will pay for keeping our instance running</h3>
         </a>
 
-      <div [innerHTML]="html.businessModel"></div>
+      <div [innerHTML]="aboutHTML.businessModel"></div>
     </div>
 
     <div class="anchor" id="information"></div>
     <a
-      *ngIf="descriptionContent"
+      *ngIf="descriptionElement"
       class="anchor-link"
       routerLink="/about/instance"
       fragment="information"
         <h3 i18n class="section-title">Description</h3>
       </a>
 
-      <my-custom-markup-container [content]="descriptionContent"></my-custom-markup-container>
+      <my-custom-markup-container [content]="descriptionElement"></my-custom-markup-container>
     </div>
 
     <div myPluginSelector pluginSelectorId="about-instance-moderation">
       <div class="anchor" id="moderation"></div>
       <a
-        *ngIf="html.moderationInformation || html.codeOfConduct || html.terms"
+        *ngIf="aboutHTML.moderationInformation || aboutHTML.codeOfConduct || aboutHTML.terms"
         class="anchor-link"
         routerLink="/about/instance"
         fragment="moderation"
         </h2>
       </a>
 
-      <div class="block moderation-information" *ngIf="html.moderationInformation">
+      <div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
         <div class="anchor" id="moderation-information"></div>
         <a
           class="anchor-link"
           <h3 i18n class="section-title">Moderation information</h3>
         </a>
 
-        <div [innerHTML]="html.moderationInformation"></div>
+        <div [innerHTML]="aboutHTML.moderationInformation"></div>
       </div>
 
-      <div class="block code-of-conduct" *ngIf="html.codeOfConduct">
+      <div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
         <div class="anchor" id="code-of-conduct"></div>
         <a
           class="anchor-link"
           <h3 i18n class="section-title">Code of conduct</h3>
         </a>
 
-        <div [innerHTML]="html.codeOfConduct"></div>
+        <div [innerHTML]="aboutHTML.codeOfConduct"></div>
       </div>
 
       <div class="block terms">
           <h3 i18n class="section-title">Terms</h3>
         </a>
 
-        <div [innerHTML]="html.terms"></div>
+        <div [innerHTML]="aboutHTML.terms"></div>
       </div>
     </div>
 
     <div myPluginSelector pluginSelectorId="about-instance-other-information">
       <div class="anchor" id="other-information"></div>
       <a
-        *ngIf="html.hardwareInformation"
+        *ngIf="aboutHTML.hardwareInformation"
         class="anchor-link"
         routerLink="/about/instance"
         fragment="other-information"
         </h2>
       </a>
 
-      <div class="block hardware-information" *ngIf="html.hardwareInformation">
+      <div class="block hardware-information" *ngIf="aboutHTML.hardwareInformation">
         <div class="anchor" id="hardware-information"></div>
         <a
           class="anchor-link"
           <h3 i18n class="section-title">Hardware information</h3>
         </a>
 
-        <div [innerHTML]="html.hardwareInformation"></div>
+        <div [innerHTML]="aboutHTML.hardwareInformation"></div>
       </div>
     </div>
   </div>
index 0826bbc5a198bfb59c0b50b0a1ab48b9fdf9e6f2..e1501d7de02491b39c0c5aa0b2841a5a2f639379 100644 (file)
@@ -2,7 +2,7 @@ import { ViewportScroller } from '@angular/common'
 import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
 import { Notifier, ServerService } from '@app/core'
-import { InstanceService } from '@app/shared/shared-instance'
+import { AboutHTML } from '@app/shared/shared-instance'
 import { copyToClipboard } from '@root-helpers/utils'
 import { HTMLServerConfig } from '@shared/models/server'
 import { ResolverData } from './about-instance.resolver'
@@ -17,22 +17,12 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
   @ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement>
   @ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
 
-  shortDescription = ''
-  descriptionContent: string
-
-  html = {
-    terms: '',
-    codeOfConduct: '',
-    moderationInformation: '',
-    administrator: '',
-    creationReason: '',
-    maintenanceLifetime: '',
-    businessModel: '',
-    hardwareInformation: ''
-  }
+  aboutHTML: AboutHTML
+  descriptionElement: HTMLDivElement
 
   languages: string[] = []
   categories: string[] = []
+  shortDescription = ''
 
   initialized = false
 
@@ -44,8 +34,7 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
     private viewportScroller: ViewportScroller,
     private route: ActivatedRoute,
     private notifier: Notifier,
-    private serverService: ServerService,
-    private instanceService: InstanceService
+    private serverService: ServerService
   ) {}
 
   get instanceName () {
@@ -60,8 +49,16 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
     return this.serverConfig.instance.isNSFW
   }
 
-  async ngOnInit () {
-    const { about, languages, categories }: ResolverData = this.route.snapshot.data.instanceData
+  ngOnInit () {
+    const { about, languages, categories, aboutHTML, descriptionElement }: ResolverData = this.route.snapshot.data.instanceData
+
+    this.aboutHTML = aboutHTML
+    this.descriptionElement = descriptionElement
+
+    this.languages = languages
+    this.categories = categories
+
+    this.shortDescription = about.instance.shortDescription
 
     this.serverConfig = this.serverService.getHTMLConfig()
 
@@ -73,14 +70,6 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
       this.contactAdminModal.show(prefill)
     })
 
-    this.languages = languages
-    this.categories = categories
-
-    this.shortDescription = about.instance.shortDescription
-    this.descriptionContent = about.instance.description
-
-    this.html = await this.instanceService.buildHtml(about)
-
     this.initialized = true
   }
 
index ee0219df0cfa36f1676d09fc09729d30aae59668..8818fc5825f6113ee9f64b173bed47f92683d14d 100644 (file)
@@ -2,16 +2,25 @@ import { forkJoin } from 'rxjs'
 import { map, switchMap } from 'rxjs/operators'
 import { Injectable } from '@angular/core'
 import { Resolve } from '@angular/router'
-import { InstanceService } from '@app/shared/shared-instance'
+import { CustomMarkupService } from '@app/shared/shared-custom-markup'
+import { AboutHTML, InstanceService } from '@app/shared/shared-instance'
 import { About } from '@shared/models/server'
 
-export type ResolverData = { about: About, languages: string[], categories: string[] }
+export type ResolverData = {
+  about: About
+  languages: string[]
+  categories: string[]
+  aboutHTML: AboutHTML
+  descriptionElement: HTMLDivElement
+}
 
 @Injectable()
 export class AboutInstanceResolver implements Resolve<any> {
 
   constructor (
-    private instanceService: InstanceService
+    private instanceService: InstanceService,
+    private customMarkupService: CustomMarkupService
+
   ) {}
 
   resolve () {
@@ -19,9 +28,15 @@ export class AboutInstanceResolver implements Resolve<any> {
                .pipe(
                  switchMap(about => {
                    return forkJoin([
+                     Promise.resolve(about),
                      this.instanceService.buildTranslatedLanguages(about),
-                     this.instanceService.buildTranslatedCategories(about)
-                   ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories }) as ResolverData))
+                     this.instanceService.buildTranslatedCategories(about),
+                     this.instanceService.buildHtml(about),
+                     this.customMarkupService.buildElement(about.instance.description)
+                   ])
+                 }),
+                 map(([ about, languages, categories, aboutHTML, { rootElement } ]) => {
+                   return { about, languages, categories, aboutHTML, descriptionElement: rootElement } as ResolverData
                  })
                )
   }
index 746549555d9e266ae6249cd09c9b85269abf103c..630bfe25392a10f6ff2b0f30415bf669c670171c 100644 (file)
@@ -96,6 +96,14 @@ export class AdminComponent implements OnInit {
       children: []
     }
 
+    if (this.hasRegistrationsRight()) {
+      moderationItems.children.push({
+        label: $localize`Registrations`,
+        routerLink: '/admin/moderation/registrations/list',
+        iconName: 'user'
+      })
+    }
+
     if (this.hasAbusesRight()) {
       moderationItems.children.push({
         label: $localize`Reports`,
@@ -229,4 +237,8 @@ export class AdminComponent implements OnInit {
   private hasVideosRight () {
     return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS)
   }
+
+  private hasRegistrationsRight () {
+    return this.auth.getUser().hasRight(UserRight.MANAGE_REGISTRATIONS)
+  }
 }
index f01967ea6c27ee5a49d59cc8625a92f4eb5cb0b6..891ff4ed1312807c9a526dac8c12836ad0c11f1e 100644 (file)
@@ -30,7 +30,13 @@ import { FollowersListComponent, FollowModalComponent, VideoRedundanciesListComp
 import { FollowingListComponent } from './follows/following-list/following-list.component'
 import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
 import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
-import { AbuseListComponent, VideoBlockListComponent } from './moderation'
+import {
+  AbuseListComponent,
+  AdminRegistrationService,
+  ProcessRegistrationModalComponent,
+  RegistrationListComponent,
+  VideoBlockListComponent
+} from './moderation'
 import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
 import {
   UserCreateComponent,
@@ -116,7 +122,10 @@ import { JobsComponent } from './system/jobs/jobs.component'
     EditLiveConfigurationComponent,
     EditAdvancedConfigurationComponent,
     EditInstanceInformationComponent,
-    EditHomepageComponent
+    EditHomepageComponent,
+
+    RegistrationListComponent,
+    ProcessRegistrationModalComponent
   ],
 
   exports: [
@@ -130,7 +139,8 @@ import { JobsComponent } from './system/jobs/jobs.component'
     ConfigService,
     PluginApiService,
     EditConfigurationService,
-    VideoAdminService
+    VideoAdminService,
+    AdminRegistrationService
   ]
 })
 export class AdminModule { }
index 43f1438e0cabe6b77ad4865a88e93e99c6428a44..0f3803f97e166e38325dc2983289cce20fa88f3a 100644 (file)
 
             <div class="peertube-select-container">
               <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
+                <option i18n value="publishedAt">Recently added videos</option>
+                <option i18n value="originallyPublishedAt">Original publication date</option>
+                <option i18n value="name">Name</option>
                 <option i18n value="hot">Hot videos</option>
-                <option i18n value="most-viewed">Most viewed videos</option>
+                <option i18n value="most-viewed">Recent views</option>
                 <option i18n value="most-liked">Most liked videos</option>
+                <option i18n value="views">Global views</option>
               </select>
             </div>
 
             </ng-container>
 
             <ng-container ngProjectAs="extra">
-              <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
-                inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
-                i18n-labelText labelText="Signup requires email verification"
-              ></my-peertube-checkbox>
+              <div class="form-group">
+                <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
+                  inputName="signupRequiresApproval" formControlName="requiresApproval"
+                  i18n-labelText labelText="Signup requires approval by moderators"
+                ></my-peertube-checkbox>
+              </div>
 
-              <div [ngClass]="getDisabledSignupClass()" class="mt-3">
+              <div class="form-group">
+                <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
+                  inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
+                  i18n-labelText labelText="Signup requires email verification"
+                ></my-peertube-checkbox>
+              </div>
+
+              <div [ngClass]="getDisabledSignupClass()">
                 <label i18n for="signupLimit">Signup limit</label>
 
                 <div class="number-with-unit">
index 168f4702c1fad95581a432dc7dab7fb476d4137b..2afe80a037011d226fc24a98707daa6bb32d6ea4 100644 (file)
@@ -132,6 +132,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
       signup: {
         enabled: null,
         limit: SIGNUP_LIMIT_VALIDATOR,
+        requiresApproval: null,
         requiresEmailVerification: null,
         minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
       },
index 5339240bb15253e94a797e2876459be5f521adb9..3d8414f5c799e54dec07360b9ecb85bf44f1f9bc 100644 (file)
@@ -17,7 +17,7 @@
 
           <my-markdown-textarea
             name="instanceCustomHomepageContent" formControlName="content"
-            [customMarkdownRenderer]="getCustomMarkdownRenderer()"
+            [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
             [formError]="formErrors['instanceCustomHomepage.content']"
           ></my-markdown-textarea>
 
index b54733327f0278a06233199c4b341e978b86d052..504afa189eba0c262be4acf05930dee917c3f592 100644 (file)
@@ -38,7 +38,7 @@
 
           <my-markdown-textarea
             name="instanceDescription" formControlName="description"
-            [customMarkdownRenderer]="getCustomMarkdownRenderer()"
+            [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
             [formError]="formErrors['instance.description']"
           ></my-markdown-textarea>
         </div>
index 8fe0d23484c4b0380708d674225e00efbc4e482e..14c62f1af0bb2bddb468880fe44e154af1993308 100644 (file)
@@ -9,14 +9,14 @@
   [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
   [showCurrentPageReport]="true" i18n-currentPageReportTemplate
   currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers"
-  [(selection)]="selectedFollows"
+  [(selection)]="selectedRows"
 >
   <ng-template pTemplate="caption">
     <div class="caption">
       <div class="left-buttons">
         <my-action-dropdown
           *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
-          [actions]="bulkFollowsActions" [entry]="selectedFollows"
+          [actions]="bulkActions" [entry]="selectedRows"
         >
         </my-action-dropdown>
       </div>
index b2d333e83ab2ded80eb14b391f40af1d99045159..cebb2e1a2d779ce5e41eca8d7965b2385fb580ae 100644 (file)
@@ -12,7 +12,7 @@ import { ActorFollow } from '@shared/models'
   templateUrl: './followers-list.component.html',
   styleUrls: [ './followers-list.component.scss' ]
 })
-export class FollowersListComponent extends RestTable implements OnInit {
+export class FollowersListComponent extends RestTable <ActorFollow> implements OnInit {
   followers: ActorFollow[] = []
   totalRecords = 0
   sort: SortMeta = { field: 'createdAt', order: -1 }
@@ -20,8 +20,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
 
   searchFilters: AdvancedInputFilter[] = []
 
-  selectedFollows: ActorFollow[] = []
-  bulkFollowsActions: DropdownAction<ActorFollow[]>[] = []
+  bulkActions: DropdownAction<ActorFollow[]>[] = []
 
   constructor (
     private confirmService: ConfirmService,
@@ -36,7 +35,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
 
     this.searchFilters = this.followService.buildFollowsListFilters()
 
-    this.bulkFollowsActions = [
+    this.bulkActions = [
       {
         label: $localize`Reject`,
         handler: follows => this.rejectFollower(follows),
@@ -105,12 +104,14 @@ export class FollowersListComponent extends RestTable implements OnInit {
   }
 
   async deleteFollowers (follows: ActorFollow[]) {
+    const icuParams = { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
+
     let message = $localize`Deleted followers will be able to send again a follow request.`
     message += '<br /><br />'
 
     // eslint-disable-next-line max-len
     message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)(
-      { count: follows.length, followerName: this.buildFollowerName(follows[0]) },
+      icuParams,
       $localize`Do you really want to delete these follow requests?`
     )
 
@@ -122,7 +123,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
           next: () => {
             // eslint-disable-next-line max-len
             const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)(
-              { count: follows.length, followerName: this.buildFollowerName(follows[0]) },
+              icuParams,
               $localize`Follow requests removed`
             )
 
@@ -139,11 +140,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
     return follow.follower.name + '@' + follow.follower.host
   }
 
-  isInSelectionMode () {
-    return this.selectedFollows.length !== 0
-  }
-
-  protected reloadData () {
+  protected reloadDataInternal () {
     this.followService.getFollowers({ pagination: this.pagination, sort: this.sort, search: this.search })
                       .subscribe({
                         next: resultList => {
index f7abb7edee9a0e6956d556e6fcfa08290d53392b..eca79be71e1f47ed3cf2d86782bae0970e9c87d9 100644 (file)
@@ -9,14 +9,14 @@
   [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
   [showCurrentPageReport]="true" i18n-currentPageReportTemplate
   currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts"
-  [(selection)]="selectedFollows"
+  [(selection)]="selectedRows"
 >
   <ng-template pTemplate="caption">
     <div class="caption">
       <div class="left-buttons">
         <my-action-dropdown
           *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
-          [actions]="bulkFollowsActions" [entry]="selectedFollows"
+          [actions]="bulkActions" [entry]="selectedRows"
         >
         </my-action-dropdown>
 
index e3a56651a22fa7a2437e3679fd69ba83c8ae0c4f..71f2fbe665914a8dd65fec0e5585b8c685fe24bd 100644 (file)
@@ -12,7 +12,7 @@ import { prepareIcu } from '@app/helpers'
   templateUrl: './following-list.component.html',
   styleUrls: [ './following-list.component.scss' ]
 })
-export class FollowingListComponent extends RestTable implements OnInit {
+export class FollowingListComponent extends RestTable <ActorFollow> implements OnInit {
   @ViewChild('followModal') followModal: FollowModalComponent
 
   following: ActorFollow[] = []
@@ -22,8 +22,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
 
   searchFilters: AdvancedInputFilter[] = []
 
-  selectedFollows: ActorFollow[] = []
-  bulkFollowsActions: DropdownAction<ActorFollow[]>[] = []
+  bulkActions: DropdownAction<ActorFollow[]>[] = []
 
   constructor (
     private notifier: Notifier,
@@ -38,7 +37,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
 
     this.searchFilters = this.followService.buildFollowsListFilters()
 
-    this.bulkFollowsActions = [
+    this.bulkActions = [
       {
         label: $localize`Delete`,
         handler: follows => this.removeFollowing(follows)
@@ -58,17 +57,15 @@ export class FollowingListComponent extends RestTable implements OnInit {
     return follow.following.name === 'peertube'
   }
 
-  isInSelectionMode () {
-    return this.selectedFollows.length !== 0
-  }
-
   buildFollowingName (follow: ActorFollow) {
     return follow.following.name + '@' + follow.following.host
   }
 
   async removeFollowing (follows: ActorFollow[]) {
+    const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) }
+
     const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)(
-      { count: follows.length, entryName: this.buildFollowingName(follows[0]) },
+      icuParams,
       $localize`Do you really want to unfollow these entries?`
     )
 
@@ -80,7 +77,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
         next: () => {
           // eslint-disable-next-line max-len
           const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)(
-            { count: follows.length, entryName: this.buildFollowingName(follows[0]) },
+            icuParams,
             $localize`You are not following them anymore.`
           )
 
@@ -92,7 +89,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
       })
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     this.followService.getFollowing({ pagination: this.pagination, sort: this.sort, search: this.search })
                       .subscribe({
                         next: resultList => {
index a89603048cb8dde6731358268d48e03f88df785c..b31c5b35e6537e3c6ec1c816fcbc655a2f5bc673 100644 (file)
@@ -162,7 +162,7 @@ export class VideoRedundanciesListComponent extends RestTable implements OnInit
 
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     const options = {
       pagination: this.pagination,
       sort: this.sort,
index 9dab270ccbea293ada736cd726f6a74daf12a35d..135b4b4084150e3ac0068b007357a44dd50307a8 100644 (file)
@@ -1,4 +1,5 @@
 export * from './abuse-list'
 export * from './instance-blocklist'
 export * from './video-block-list'
+export * from './registration-list'
 export * from './moderation.routes'
index 1ad3010394018bf435c0278a0d921a34432e7892..378d2bed738f32cd129c8acdc241b5788208a6c0 100644 (file)
@@ -4,6 +4,7 @@ import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } f
 import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
 import { UserRightGuard } from '@app/core'
 import { UserRight } from '@shared/models'
+import { RegistrationListComponent } from './registration-list'
 
 export const ModerationRoutes: Routes = [
   {
@@ -68,7 +69,19 @@ export const ModerationRoutes: Routes = [
         }
       },
 
-      // We move this component in admin overview pages
+      {
+        path: 'registrations/list',
+        component: RegistrationListComponent,
+        canActivate: [ UserRightGuard ],
+        data: {
+          userRight: UserRight.MANAGE_REGISTRATIONS,
+          meta: {
+            title: $localize`User registrations`
+          }
+        }
+      },
+
+      // We moved this component in admin overview pages
       {
         path: 'video-comments',
         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 (file)
index 0000000..a9f13cf
--- /dev/null
@@ -0,0 +1,81 @@
+import { SortMeta } from 'primeng/api'
+import { from } from 'rxjs'
+import { catchError, concatMap, toArray } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { arrayify } from '@shared/core-utils'
+import { ResultList, UserRegistration, UserRegistrationUpdateState } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+
+@Injectable()
+export class AdminRegistrationService {
+  private static BASE_REGISTRATION_URL = environment.apiUrl + '/api/v1/users/registrations'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor,
+    private restService: RestService
+  ) { }
+
+  listRegistrations (options: {
+    pagination: RestPagination
+    sort: SortMeta
+    search?: string
+  }) {
+    const { pagination, sort, search } = options
+
+    const url = AdminRegistrationService.BASE_REGISTRATION_URL
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+
+    if (search) {
+      params = params.append('search', search)
+    }
+
+    return this.authHttp.get<ResultList<UserRegistration>>(url, { params })
+      .pipe(
+        catchError(res => this.restExtractor.handleError(res))
+      )
+  }
+
+  acceptRegistration (options: {
+    registration: UserRegistration
+    moderationResponse: string
+    preventEmailDelivery: boolean
+  }) {
+    const { registration, moderationResponse, preventEmailDelivery } = options
+
+    const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/accept'
+    const body: UserRegistrationUpdateState = { moderationResponse, preventEmailDelivery }
+
+    return this.authHttp.post(url, body)
+      .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+  rejectRegistration (options: {
+    registration: UserRegistration
+    moderationResponse: string
+    preventEmailDelivery: boolean
+  }) {
+    const { registration, moderationResponse, preventEmailDelivery } = options
+
+    const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/reject'
+    const body: UserRegistrationUpdateState = { moderationResponse, preventEmailDelivery }
+
+    return this.authHttp.post(url, body)
+      .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+  removeRegistrations (registrationsArg: UserRegistration | UserRegistration[]) {
+    const registrations = arrayify(registrationsArg)
+
+    return from(registrations)
+      .pipe(
+        concatMap(r => this.authHttp.delete(AdminRegistrationService.BASE_REGISTRATION_URL + '/' + r.id)),
+        toArray(),
+        catchError(err => this.restExtractor.handleError(err))
+      )
+  }
+}
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 (file)
index 0000000..060b676
--- /dev/null
@@ -0,0 +1,4 @@
+export * from './admin-registration.service'
+export * from './process-registration-modal.component'
+export * from './process-registration-validators'
+export * 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 (file)
index 0000000..8e46b0c
--- /dev/null
@@ -0,0 +1,74 @@
+<ng-template #modal>
+  <div class="modal-header">
+    <h4 i18n class="modal-title">
+      <ng-container *ngIf="isAccept()">Accept {{ registration.username }} registration</ng-container>
+      <ng-container *ngIf="isReject()">Reject {{ registration.username }} registration</ng-container>
+    </h4>
+
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+  </div>
+
+  <form novalidate [formGroup]="form" (ngSubmit)="processRegistration()">
+    <div class="modal-body mb-3">
+
+      <div i18n *ngIf="!registration.emailVerified" class="alert alert-warning">
+        Registration email has not been verified. Email delivery has been disabled by default.
+      </div>
+
+      <div class="description">
+        <ng-container *ngIf="isAccept()">
+          <p i18n>
+            <strong>Accepting</strong>&nbsp;<em>{{ registration.username }}</em> registration will create the account and channel.
+          </p>
+
+          <p *ngIf="isEmailEnabled()" i18n [ngClass]="{ 'text-decoration-line-through': isPreventEmailDeliveryChecked() }">
+            An email will be sent to <em>{{ registration.email }}</em> explaining its account has been created with the moderation response you'll write below.
+          </p>
+
+          <div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
+            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.
+          </div>
+        </ng-container>
+
+        <ng-container *ngIf="isReject()">
+          <p i18n [ngClass]="{ 'text-decoration-line-through': isPreventEmailDeliveryChecked() }">
+            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.
+          </p>
+
+          <div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
+            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.
+          </div>
+        </ng-container>
+      </div>
+
+      <div class="form-group">
+        <label for="moderationResponse" i18n>Send a message to the user</label>
+
+        <textarea
+          formControlName="moderationResponse" ngbAutofocus name="moderationResponse" id="moderationResponse"
+          [ngClass]="{ 'input-error': formErrors['moderationResponse'] }" class="form-control"
+        ></textarea>
+
+        <div *ngIf="formErrors.moderationResponse" class="form-error">
+          {{ formErrors.moderationResponse }}
+        </div>
+      </div>
+
+      <div class="form-group">
+        <my-peertube-checkbox
+          inputName="preventEmailDelivery" formControlName="preventEmailDelivery" [disabled]="!isEmailEnabled()"
+          i18n-labelText labelText="Prevent email from being sent to the user"
+        ></my-peertube-checkbox>
+      </div>
+    </div>
+
+    <div class="modal-footer inputs">
+      <input
+        type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
+        (click)="hide()" (key.enter)="hide()"
+      >
+
+      <input type="submit" [value]="getSubmitValue()" class="peertube-button orange-button" [disabled]="!form.valid">
+    </div>
+  </form>
+</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 (file)
index 0000000..3e03bed
--- /dev/null
@@ -0,0 +1,3 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
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 (file)
index 0000000..3a7e5de
--- /dev/null
@@ -0,0 +1,122 @@
+import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
+import { Notifier, ServerService } from '@app/core'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { UserRegistration } from '@shared/models'
+import { AdminRegistrationService } from './admin-registration.service'
+import { REGISTRATION_MODERATION_RESPONSE_VALIDATOR } from './process-registration-validators'
+
+@Component({
+  selector: 'my-process-registration-modal',
+  templateUrl: './process-registration-modal.component.html',
+  styleUrls: [ './process-registration-modal.component.scss' ]
+})
+export class ProcessRegistrationModalComponent extends FormReactive implements OnInit {
+  @ViewChild('modal', { static: true }) modal: NgbModal
+
+  @Output() registrationProcessed = new EventEmitter()
+
+  registration: UserRegistration
+
+  private openedModal: NgbModalRef
+  private processMode: 'accept' | 'reject'
+
+  constructor (
+    protected formReactiveService: FormReactiveService,
+    private server: ServerService,
+    private modalService: NgbModal,
+    private notifier: Notifier,
+    private registrationService: AdminRegistrationService
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      moderationResponse: REGISTRATION_MODERATION_RESPONSE_VALIDATOR,
+      preventEmailDelivery: null
+    })
+  }
+
+  isAccept () {
+    return this.processMode === 'accept'
+  }
+
+  isReject () {
+    return this.processMode === 'reject'
+  }
+
+  openModal (registration: UserRegistration, mode: 'accept' | 'reject') {
+    this.processMode = mode
+    this.registration = registration
+
+    this.form.patchValue({
+      preventEmailDelivery: !this.isEmailEnabled() || registration.emailVerified !== true
+    })
+
+    this.openedModal = this.modalService.open(this.modal, { centered: true })
+  }
+
+  hide () {
+    this.form.reset()
+
+    this.openedModal.close()
+  }
+
+  getSubmitValue () {
+    if (this.isAccept()) {
+      return $localize`Accept registration`
+    }
+
+    return $localize`Reject registration`
+  }
+
+  processRegistration () {
+    if (this.isAccept()) return this.acceptRegistration()
+
+    return this.rejectRegistration()
+  }
+
+  isEmailEnabled () {
+    return this.server.getHTMLConfig().email.enabled
+  }
+
+  isPreventEmailDeliveryChecked () {
+    return this.form.value.preventEmailDelivery
+  }
+
+  private acceptRegistration () {
+    this.registrationService.acceptRegistration({
+      registration: this.registration,
+      moderationResponse: this.form.value.moderationResponse,
+      preventEmailDelivery: this.form.value.preventEmailDelivery
+    }).subscribe({
+      next: () => {
+        this.notifier.success($localize`${this.registration.username} account created`)
+
+        this.registrationProcessed.emit()
+        this.hide()
+      },
+
+      error: err => this.notifier.error(err.message)
+    })
+  }
+
+  private rejectRegistration () {
+    this.registrationService.rejectRegistration({
+      registration: this.registration,
+      moderationResponse: this.form.value.moderationResponse,
+      preventEmailDelivery: this.form.value.preventEmailDelivery
+    }).subscribe({
+      next: () => {
+        this.notifier.success($localize`${this.registration.username} registration rejected`)
+
+        this.registrationProcessed.emit()
+        this.hide()
+      },
+
+      error: err => this.notifier.error(err.message)
+    })
+  }
+}
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 (file)
index 0000000..e01a07d
--- /dev/null
@@ -0,0 +1,11 @@
+import { Validators } from '@angular/forms'
+import { BuildFormValidator } from '@app/shared/form-validators'
+
+export const REGISTRATION_MODERATION_RESPONSE_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
+  MESSAGES: {
+    required: $localize`Moderation response is required.`,
+    minlength: $localize`Moderation response must be at least 2 characters long.`,
+    maxlength: $localize`Moderation response cannot be more than 3000 characters long.`
+  }
+}
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 (file)
index 0000000..a2b8881
--- /dev/null
@@ -0,0 +1,135 @@
+<h1>
+  <my-global-icon iconName="user" aria-hidden="true"></my-global-icon>
+  <ng-container i18n>Registration requests</ng-container>
+</h1>
+
+<p-table
+  [value]="registrations" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
+  [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
+  [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
+  [(selection)]="selectedRows" [showCurrentPageReport]="true" i18n-currentPageReportTemplate
+  currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} registrations"
+  [expandedRowKeys]="expandedRows"
+>
+  <ng-template pTemplate="caption">
+    <div class="caption">
+      <div class="left-buttons">
+        <my-action-dropdown
+          *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
+          [actions]="bulkActions" [entry]="selectedRows"
+        >
+        </my-action-dropdown>
+      </div>
+
+      <div class="ms-auto">
+        <my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
+      </div>
+    </div>
+  </ng-template>
+
+  <ng-template pTemplate="header">
+    <tr> <!-- header -->
+      <th style="width: 40px">
+        <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
+      </th>
+      <th style="width: 40px;"></th>
+      <th style="width: 150px;"></th>
+      <th i18n>Account</th>
+      <th i18n>Email</th>
+      <th i18n>Channel</th>
+      <th i18n>Registration reason</th>
+      <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
+      <th i18n>Moderation response</th>
+      <th style="width: 150px;" i18n pSortableColumn="createdAt">Requested on <p-sortIcon field="createdAt"></p-sortIcon></th>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="body" let-expanded="expanded" let-registration>
+    <tr [pSelectableRow]="registration">
+      <td class="checkbox-cell">
+        <p-tableCheckbox [value]="registration" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
+      </td>
+
+      <td class="expand-cell" [pRowToggler]="registration">
+        <my-table-expander-icon [expanded]="expanded"></my-table-expander-icon>
+      </td>
+
+      <td class="action-cell">
+        <my-action-dropdown
+          [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
+          i18n-label label="Actions" [actions]="registrationActions" [entry]="registration"
+        ></my-action-dropdown>
+      </td>
+
+      <td>
+        <div class="chip two-lines">
+          <div>
+            <span>{{ registration.username }}</span>
+            <span class="muted">{{ registration.accountDisplayName }}</span>
+          </div>
+        </div>
+      </td>
+
+      <td>
+        <my-user-email-info [entry]="registration" [requiresEmailVerification]="requiresEmailVerification"></my-user-email-info>
+      </td>
+
+      <td>
+        <div class="chip two-lines">
+          <div>
+            <span>{{ registration.channelHandle }}</span>
+            <span class="muted">{{ registration.channelDisplayName }}</span>
+          </div>
+        </div>
+      </td>
+
+      <td container="body" placement="left auto" [ngbTooltip]="registration.registrationReason">
+        {{ registration.registrationReason }}
+      </td>
+
+      <td class="c-hand abuse-states" [pRowToggler]="registration">
+        <my-global-icon *ngIf="isRegistrationAccepted(registration)" [title]="registration.state.label" iconName="tick"></my-global-icon>
+        <my-global-icon *ngIf="isRegistrationRejected(registration)" [title]="registration.state.label" iconName="cross"></my-global-icon>
+      </td>
+
+      <td container="body" placement="left auto" [ngbTooltip]="registration.moderationResponse">
+        {{ registration.moderationResponse }}
+      </td>
+
+      <td class="c-hand" [pRowToggler]="registration">{{ registration.createdAt | date: 'short'  }}</td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="rowexpansion" let-registration>
+    <tr>
+      <td colspan="9">
+        <div class="moderation-expanded">
+          <div class="left">
+            <div class="d-flex">
+              <span class="moderation-expanded-label" i18n>Registration reason:</span>
+              <span class="moderation-expanded-text" [innerHTML]="registration.registrationReasonHTML"></span>
+            </div>
+
+            <div *ngIf="registration.moderationResponse">
+              <span class="moderation-expanded-label" i18n>Moderation response:</span>
+              <span class="moderation-expanded-text" [innerHTML]="registration.moderationResponseHTML"></span>
+            </div>
+          </div>
+        </div>
+      </td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="emptymessage">
+    <tr>
+      <td colspan="9">
+        <div class="no-results">
+          <ng-container *ngIf="search" i18n>No registrations found matching current filters.</ng-container>
+          <ng-container *ngIf="!search" i18n>No registrations found.</ng-container>
+        </div>
+      </td>
+    </tr>
+  </ng-template>
+</p-table>
+
+<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 (file)
index 0000000..9cae08e
--- /dev/null
@@ -0,0 +1,7 @@
+@use '_mixins' as *;
+@use '_variables' as *;
+
+my-global-icon {
+  width: 24px;
+  height: 24px;
+}
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 (file)
index 0000000..ed8fbec
--- /dev/null
@@ -0,0 +1,151 @@
+import { SortMeta } from 'primeng/api'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
+import { prepareIcu } from '@app/helpers'
+import { AdvancedInputFilter } from '@app/shared/shared-forms'
+import { DropdownAction } from '@app/shared/shared-main'
+import { UserRegistration, UserRegistrationState } from '@shared/models'
+import { AdminRegistrationService } from './admin-registration.service'
+import { ProcessRegistrationModalComponent } from './process-registration-modal.component'
+
+@Component({
+  selector: 'my-registration-list',
+  templateUrl: './registration-list.component.html',
+  styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './registration-list.component.scss' ]
+})
+export class RegistrationListComponent extends RestTable <UserRegistration> implements OnInit {
+  @ViewChild('processRegistrationModal', { static: true }) processRegistrationModal: ProcessRegistrationModalComponent
+
+  registrations: (UserRegistration & { registrationReasonHTML?: string, moderationResponseHTML?: string })[] = []
+  totalRecords = 0
+  sort: SortMeta = { field: 'createdAt', order: -1 }
+  pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
+
+  registrationActions: DropdownAction<UserRegistration>[][] = []
+  bulkActions: DropdownAction<UserRegistration[]>[] = []
+
+  inputFilters: AdvancedInputFilter[] = []
+
+  requiresEmailVerification: boolean
+
+  constructor (
+    protected route: ActivatedRoute,
+    protected router: Router,
+    private server: ServerService,
+    private notifier: Notifier,
+    private markdownRenderer: MarkdownService,
+    private confirmService: ConfirmService,
+    private adminRegistrationService: AdminRegistrationService
+  ) {
+    super()
+
+    this.registrationActions = [
+      [
+        {
+          label: $localize`Accept this request`,
+          handler: registration => this.openRegistrationRequestProcessModal(registration, 'accept'),
+          isDisplayed: registration => registration.state.id === UserRegistrationState.PENDING
+        },
+        {
+          label: $localize`Reject this request`,
+          handler: registration => this.openRegistrationRequestProcessModal(registration, 'reject'),
+          isDisplayed: registration => registration.state.id === UserRegistrationState.PENDING
+        },
+        {
+          label: $localize`Remove this request`,
+          handler: registration => this.removeRegistrations([ registration ])
+        }
+      ]
+    ]
+
+    this.bulkActions = [
+      {
+        label: $localize`Delete`,
+        handler: registrations => this.removeRegistrations(registrations)
+      }
+    ]
+  }
+
+  ngOnInit () {
+    this.initialize()
+
+    this.server.getConfig()
+      .subscribe(config => {
+        this.requiresEmailVerification = config.signup.requiresEmailVerification
+      })
+  }
+
+  getIdentifier () {
+    return 'RegistrationListComponent'
+  }
+
+  isRegistrationAccepted (registration: UserRegistration) {
+    return registration.state.id === UserRegistrationState.ACCEPTED
+  }
+
+  isRegistrationRejected (registration: UserRegistration) {
+    return registration.state.id === UserRegistrationState.REJECTED
+  }
+
+  onRegistrationProcessed () {
+    this.reloadData()
+  }
+
+  protected reloadDataInternal () {
+    this.adminRegistrationService.listRegistrations({
+      pagination: this.pagination,
+      sort: this.sort,
+      search: this.search
+    }).subscribe({
+      next: async resultList => {
+        this.totalRecords = resultList.total
+        this.registrations = resultList.data
+
+        for (const registration of this.registrations) {
+          registration.registrationReasonHTML = await this.toHtml(registration.registrationReason)
+          registration.moderationResponseHTML = await this.toHtml(registration.moderationResponse)
+        }
+      },
+
+      error: err => this.notifier.error(err.message)
+    })
+  }
+
+  private openRegistrationRequestProcessModal (registration: UserRegistration, mode: 'accept' | 'reject') {
+    this.processRegistrationModal.openModal(registration, mode)
+  }
+
+  private async removeRegistrations (registrations: UserRegistration[]) {
+    const icuParams = { count: registrations.length, username: registrations[0].username }
+
+    // eslint-disable-next-line max-len
+    const message = prepareIcu($localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`)(
+      icuParams,
+      $localize`Do you really want to delete these registration requests?`
+    )
+
+    const res = await this.confirmService.confirm(message, $localize`Delete`)
+    if (res === false) return
+
+    this.adminRegistrationService.removeRegistrations(registrations)
+      .subscribe({
+        next: () => {
+          // eslint-disable-next-line max-len
+          const message = prepareIcu($localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`)(
+            icuParams,
+            $localize`Registration requests removed`
+          )
+
+          this.notifier.success(message)
+          this.reloadData()
+        },
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
+
+  private toHtml (text: string) {
+    return this.markdownRenderer.textMarkdownToHTML({ markdown: text })
+  }
+}
index efd99e52bc0f68de4e0716ad48a824e367309c31..f365a2500f9ecbcea608a28a44ed3f7b829dbb72 100644 (file)
@@ -159,26 +159,25 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
     })
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     this.videoBlocklistService.listBlocks({
       pagination: this.pagination,
       sort: this.sort,
       search: this.search
-    })
-      .subscribe({
-        next: async resultList => {
-          this.totalRecords = resultList.total
+    }).subscribe({
+      next: async resultList => {
+        this.totalRecords = resultList.total
 
-          this.blocklist = resultList.data
+        this.blocklist = resultList.data
 
-          for (const element of this.blocklist) {
-            Object.assign(element, {
-              reasonHtml: await this.toHtml(element.reason)
-            })
-          }
-        },
+        for (const element of this.blocklist) {
+          Object.assign(element, {
+            reasonHtml: await this.toHtml(element.reason)
+          })
+        }
+      },
 
-        error: err => this.notifier.error(err.message)
-      })
+      error: err => this.notifier.error(err.message)
+    })
   }
 }
index d2ca5f70056395119307f10afda3d7a63e5e0f47..b0d8131bfaa1266e3ff766021ccadb1220d10d25 100644 (file)
   [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
   [showCurrentPageReport]="true" i18n-currentPageReportTemplate
   currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} comments"
-  [expandedRowKeys]="expandedRows" [(selection)]="selectedComments"
+  [expandedRowKeys]="expandedRows" [(selection)]="selectedRows"
 >
   <ng-template pTemplate="caption">
     <div class="caption">
       <div>
         <my-action-dropdown
           *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
-          [actions]="bulkCommentActions" [entry]="selectedComments"
+          [actions]="bulkActions" [entry]="selectedRows"
         >
         </my-action-dropdown>
       </div>
index c95d2ffeb12e2b932678782b90b87d7c12b0ee36..28efdc0762980347729573df7ce01b68f621ee0a 100644 (file)
@@ -14,7 +14,7 @@ import { prepareIcu } from '@app/helpers'
   templateUrl: './video-comment-list.component.html',
   styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-comment-list.component.scss' ]
 })
-export class VideoCommentListComponent extends RestTable implements OnInit {
+export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> implements OnInit {
   comments: VideoCommentAdmin[]
   totalRecords = 0
   sort: SortMeta = { field: 'createdAt', order: -1 }
@@ -40,8 +40,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
     }
   ]
 
-  selectedComments: VideoCommentAdmin[] = []
-  bulkCommentActions: DropdownAction<VideoCommentAdmin[]>[] = []
+  bulkActions: DropdownAction<VideoCommentAdmin[]>[] = []
 
   inputFilters: AdvancedInputFilter[] = [
     {
@@ -100,7 +99,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
   ngOnInit () {
     this.initialize()
 
-    this.bulkCommentActions = [
+    this.bulkActions = [
       {
         label: $localize`Delete`,
         handler: comments => this.removeComments(comments),
@@ -118,11 +117,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
     return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true })
   }
 
-  isInSelectionMode () {
-    return this.selectedComments.length !== 0
-  }
-
-  reloadData () {
+  protected reloadDataInternal () {
     this.videoCommentService.getAdminVideoComments({
       pagination: this.pagination,
       sort: this.sort,
@@ -162,7 +157,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
 
         error: err => this.notifier.error(err.message),
 
-        complete: () => this.selectedComments = []
+        complete: () => this.selectedRows = []
       })
   }
 
index a96ce561c00872d503167871b47a12804191e84c..7eb5e0fc7cb568bf74b56d110e5612d54d5653f6 100644 (file)
@@ -6,7 +6,7 @@
 <p-table
   [value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
   [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true"
-  [(selection)]="selectedUsers" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
+  [(selection)]="selectedRows" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
   [showCurrentPageReport]="true" i18n-currentPageReportTemplate
   currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users"
   [expandedRowKeys]="expandedRows"
@@ -16,7 +16,7 @@
       <div class="left-buttons">
         <my-action-dropdown
           *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
-          [actions]="bulkUserActions" [entry]="selectedUsers"
+          [actions]="bulkActions" [entry]="selectedRows"
         >
         </my-action-dropdown>
 
@@ -95,7 +95,7 @@
           <div class="chip two-lines">
             <my-actor-avatar [actor]="user?.account" actorType="account" size="32"></my-actor-avatar>
             <div>
-              <span class="user-table-primary-text">{{ user.account.displayName }}</span>
+              <span>{{ user.account.displayName }}</span>
               <span class="muted">{{ user.username }}</span>
             </div>
           </div>
         <span *ngIf="!user.blocked" class="pt-badge" [ngClass]="getRoleClass(user.role.id)">{{ user.role.label }}</span>
       </td>
 
-      <td *ngIf="isSelected('email')" [title]="user.email">
-        <ng-container *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus">
-          <a class="table-email" [href]="'mailto:' + user.email">{{ user.email }}</a>
-        </ng-container>
+      <td *ngIf="isSelected('email')">
+        <my-user-email-info [entry]="user" [requiresEmailVerification]="requiresEmailVerification"></my-user-email-info>
       </td>
 
-      <ng-template #emailWithVerificationStatus>
-        <td *ngIf="user.emailVerified === false; else emailVerifiedNotFalse" i18n-title title="User's email must be verified to login">
-          <em>? {{ user.email }}</em>
-        </td>
-        <ng-template #emailVerifiedNotFalse>
-          <td i18n-title title="User's email is verified / User can login without email verification">
-            &#x2713; {{ user.email }}
-          </td>
-        </ng-template>
-      </ng-template>
-
       <td *ngIf="isSelected('quota')">
         <div class="progress" i18n-title title="Total video quota">
           <div class="progress-bar" role="progressbar" [style]="{ width: getUserVideoQuotaPercentage(user) + '%' }"
index 23e0d29ee17a9af104144981bd6987a55eab8a24..2a3b955d2d2e1db546fa877ffe9fe6b2114139a7 100644 (file)
@@ -10,12 +10,6 @@ tr.banned > td {
   background-color: lighten($color: $red, $amount: 40) !important;
 }
 
-.table-email {
-  @include disable-default-a-behaviour;
-
-  color: pvar(--mainForegroundColor);
-}
-
 .banned-info {
   font-style: italic;
 }
@@ -37,10 +31,6 @@ my-global-icon {
   width: 18px;
 }
 
-.chip {
-  @include chip;
-}
-
 .progress {
   @include progressbar($small: true);
 
index 99987fdff4303565424d481568ee4a30887f5b6b..19420b7489f95f5947871f389bd30b8797f9b745 100644 (file)
@@ -22,7 +22,7 @@ type UserForList = User & {
   templateUrl: './user-list.component.html',
   styleUrls: [ './user-list.component.scss' ]
 })
-export class UserListComponent extends RestTable implements OnInit {
+export class UserListComponent extends RestTable <User> implements OnInit {
   private static readonly LOCAL_STORAGE_SELECTED_COLUMNS_KEY = 'admin-user-list-selected-columns'
 
   @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent
@@ -35,8 +35,7 @@ export class UserListComponent extends RestTable implements OnInit {
 
   highlightBannedUsers = false
 
-  selectedUsers: User[] = []
-  bulkUserActions: DropdownAction<User[]>[][] = []
+  bulkActions: DropdownAction<User[]>[][] = []
   columns: { id: string, label: string }[]
 
   inputFilters: AdvancedInputFilter[] = [
@@ -95,7 +94,7 @@ export class UserListComponent extends RestTable implements OnInit {
 
     this.initialize()
 
-    this.bulkUserActions = [
+    this.bulkActions = [
       [
         {
           label: $localize`Delete`,
@@ -249,7 +248,7 @@ export class UserListComponent extends RestTable implements OnInit {
     const res = await this.confirmService.confirm(message, $localize`Delete`)
     if (res === false) return
 
-    this.userAdminService.removeUser(users)
+    this.userAdminService.removeUsers(users)
       .subscribe({
         next: () => {
           this.notifier.success(
@@ -284,13 +283,7 @@ export class UserListComponent extends RestTable implements OnInit {
       })
   }
 
-  isInSelectionMode () {
-    return this.selectedUsers.length !== 0
-  }
-
-  protected reloadData () {
-    this.selectedUsers = []
-
+  protected reloadDataInternal () {
     this.userAdminService.getUsers({
       pagination: this.pagination,
       sort: this.sort,
index a6cd2e257d25755ab16927641732eed819f16358..5b8405ad9cbed6ce8a63b5b509200686454ae9c7 100644 (file)
@@ -6,7 +6,7 @@
 <p-table
   [value]="videos" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
   [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true"
-  [(selection)]="selectedVideos" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
+  [(selection)]="selectedRows" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
   [showCurrentPageReport]="true" i18n-currentPageReportTemplate
   currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} videos"
   [expandedRowKeys]="expandedRows" [ngClass]="{ loading: loading }"
@@ -16,7 +16,7 @@
       <div class="left-buttons">
         <my-action-dropdown
           *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
-          [actions]="bulkVideoActions" [entry]="selectedVideos"
+          [actions]="bulkActions" [entry]="selectedRows"
         >
         </my-action-dropdown>
       </div>
index 4d3e9873cbf6a6a9fa47879524f1bdb94bb85b29..1ea29549923d573a18e6b0111dbd4102f085c175 100644 (file)
@@ -17,7 +17,7 @@ import { VideoAdminService } from './video-admin.service'
   templateUrl: './video-list.component.html',
   styleUrls: [ './video-list.component.scss' ]
 })
-export class VideoListComponent extends RestTable implements OnInit {
+export class VideoListComponent extends RestTable <Video> implements OnInit {
   @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent
 
   videos: Video[] = []
@@ -26,9 +26,7 @@ export class VideoListComponent extends RestTable implements OnInit {
   sort: SortMeta = { field: 'publishedAt', order: -1 }
   pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
 
-  bulkVideoActions: DropdownAction<Video[]>[][] = []
-
-  selectedVideos: Video[] = []
+  bulkActions: DropdownAction<Video[]>[][] = []
 
   inputFilters: AdvancedInputFilter[]
 
@@ -72,7 +70,7 @@ export class VideoListComponent extends RestTable implements OnInit {
 
     this.inputFilters = this.videoAdminService.buildAdminInputFilter()
 
-    this.bulkVideoActions = [
+    this.bulkActions = [
       [
         {
           label: $localize`Delete`,
@@ -126,10 +124,6 @@ export class VideoListComponent extends RestTable implements OnInit {
     return 'VideoListComponent'
   }
 
-  isInSelectionMode () {
-    return this.selectedVideos.length !== 0
-  }
-
   getPrivacyBadgeClass (video: Video) {
     if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green'
 
@@ -189,9 +183,23 @@ export class VideoListComponent extends RestTable implements OnInit {
     return files.reduce((p, f) => p += f.size, 0)
   }
 
-  reloadData () {
-    this.selectedVideos = []
+  async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') {
+    const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
+    const res = await this.confirmService.confirm(message, $localize`Delete file`)
+    if (res === false) return
+
+    this.videoService.removeFile(video.uuid, file.id, type)
+      .subscribe({
+        next: () => {
+          this.notifier.success($localize`File removed.`)
+          this.reloadData()
+        },
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
 
+  protected reloadDataInternal () {
     this.loading = true
 
     this.videoAdminService.getAdminVideos({
@@ -209,22 +217,6 @@ export class VideoListComponent extends RestTable implements OnInit {
       })
   }
 
-  async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') {
-    const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
-    const res = await this.confirmService.confirm(message, $localize`Delete file`)
-    if (res === false) return
-
-    this.videoService.removeFile(video.uuid, file.id, type)
-      .subscribe({
-        next: () => {
-          this.notifier.success($localize`File removed.`)
-          this.reloadData()
-        },
-
-        error: err => this.notifier.error(err.message)
-      })
-  }
-
   private async removeVideos (videos: Video[]) {
     const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)(
       { count: videos.length },
index bef7d54ef888062e4850f83bb51c7c29f8cbf750..a5c300d1244d510c1e8554b944333f7fa050b3fe 100644 (file)
@@ -1,5 +1,6 @@
 import { NgModule } from '@angular/core'
 import { SharedMainModule } from '../../shared/shared-main/shared-main.module'
+import { UserEmailInfoComponent } from './user-email-info.component'
 import { UserRealQuotaInfoComponent } from './user-real-quota-info.component'
 
 @NgModule({
@@ -8,11 +9,13 @@ import { UserRealQuotaInfoComponent } from './user-real-quota-info.component'
   ],
 
   declarations: [
-    UserRealQuotaInfoComponent
+    UserRealQuotaInfoComponent,
+    UserEmailInfoComponent
   ],
 
   exports: [
-    UserRealQuotaInfoComponent
+    UserRealQuotaInfoComponent,
+    UserEmailInfoComponent
   ],
 
   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 (file)
index 0000000..2442406
--- /dev/null
@@ -0,0 +1,13 @@
+<ng-container>
+  <a [href]="'mailto:' + entry.email" [title]="getTitle()">
+    <ng-container *ngIf="!requiresEmailVerification">
+      {{ entry.email }}
+    </ng-container>
+
+    <ng-container *ngIf="requiresEmailVerification">
+      <em *ngIf="!entry.emailVerified">? {{ entry.email }}</em>
+
+      <ng-container *ngIf="entry.emailVerified === true">&#x2713; {{ entry.email }}</ng-container>
+    </ng-container>
+  </a>
+</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 (file)
index 0000000..d34947e
--- /dev/null
@@ -0,0 +1,10 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
+a {
+  color: pvar(--mainForegroundColor);
+
+  &:hover {
+    text-decoration: underline;
+  }
+}
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 (file)
index 0000000..e33948b
--- /dev/null
@@ -0,0 +1,20 @@
+import { Component, Input } from '@angular/core'
+import { User, UserRegistration } from '@shared/models/users'
+
+@Component({
+  selector: 'my-user-email-info',
+  templateUrl: './user-email-info.component.html',
+  styleUrls: [ './user-email-info.component.scss' ]
+})
+export class UserEmailInfoComponent {
+  @Input() entry: User | UserRegistration
+  @Input() requiresEmailVerification: boolean
+
+  getTitle () {
+    if (this.entry.emailVerified) {
+      return $localize`User email has been verified`
+    }
+
+    return $localize`User email hasn't been verified`
+  }
+}
index ef8ddd3b497293d713d4b7bc84d7b60085aff018..031e2bad87e4960bef637311e43382b90ebfbd91 100644 (file)
@@ -19,7 +19,7 @@ export class JobService {
     private restExtractor: RestExtractor
   ) {}
 
-  getJobs (options: {
+  listJobs (options: {
     jobState?: JobStateClient
     jobType: JobTypeClient
     pagination: RestPagination
index b8f3c3a68c7a75e1501747d0303e24a898a9cd7d..6e10c81ff2032e680a9a69e4e88a62c4f4496eca 100644 (file)
@@ -120,12 +120,12 @@ export class JobsComponent extends RestTable implements OnInit {
     this.reloadData()
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     let jobState = this.jobState as JobState
     if (this.jobState === 'all') jobState = null
 
     this.jobsService
-      .getJobs({
+      .listJobs({
         jobState,
         jobType: this.jobType,
         pagination: this.pagination,
index c1705807f0052bbdec43d6a1ccc04089ed8c3c38..c03af38f284956c9dbdcfe2458ed08e1a764ea8d 100644 (file)
@@ -1,3 +1,4 @@
+import { environment } from 'src/environments/environment'
 import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
@@ -7,8 +8,8 @@ import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-valid
 import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
 import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
 import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
-import { PluginsManager } from '@root-helpers/plugins-manager'
-import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
+import { getExternalAuthHref } from '@shared/core-utils'
+import { RegisteredExternalAuthConfig, ServerConfig, ServerErrorCode } from '@shared/models'
 
 @Component({
   selector: 'my-login',
@@ -119,7 +120,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
   }
 
   getAuthHref (auth: RegisteredExternalAuthConfig) {
-    return PluginsManager.getExternalAuthHref(auth)
+    return getExternalAuthHref(environment.apiUrl, auth)
   }
 
   login () {
@@ -196,6 +197,8 @@ The link will expire within 1 hour.`
   }
 
   private handleError (err: any) {
+    console.log(err)
+
     if (this.authService.isOTPMissingError(err)) {
       this.otpStep = true
 
@@ -207,8 +210,26 @@ The link will expire within 1 hour.`
       return
     }
 
-    if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.`
-    else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.`
-    else this.error = err.message
+    if (err.message.includes('credentials are invalid')) {
+      this.error = $localize`Incorrect username or password.`
+      return
+    }
+
+    if (err.message.includes('blocked')) {
+      this.error = $localize`Your account is blocked.`
+      return
+    }
+
+    if (err.body?.code === ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL) {
+      this.error = $localize`This account is awaiting approval by moderators.`
+      return
+    }
+
+    if (err.body?.code === ServerErrorCode.ACCOUNT_APPROVAL_REJECTED) {
+      this.error = $localize`Registration approval has been rejected for this account.`
+      return
+    }
+
+    this.error = err.message
   }
 }
index a8450ff1b6bd1df3ebb659d30709c2a2e1b3ab46..98bed226dbcdf13f7a24e8645d1b1747f0f7c430 100644 (file)
@@ -2,10 +2,6 @@
 @use '_miniature' as *;
 @use '_mixins' as *;
 
-.chip {
-  @include chip;
-}
-
 .video-table-video {
   display: inline-flex;
 
index 7ea940ceba219507c4dbafa586f476a3a186e476..8d6a42dfb3429c571ac9734410623379d89155d2 100644 (file)
@@ -59,7 +59,7 @@ export class MyOwnershipComponent extends RestTable implements OnInit {
       })
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     return this.videoOwnershipService.getOwnershipChanges(this.pagination, this.sort)
       .subscribe({
         next: resultList => {
index d18e78201c2dc428afb1f9dfd5d4912e14f9dc7e..74dbe222dfd396faf6db1d02f86095fd899881f6 100644 (file)
@@ -68,7 +68,7 @@ export class MyVideoChannelSyncsComponent extends RestTable implements OnInit {
     ]
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     this.error = undefined
 
     this.authService.userInformationLoaded
index 46d689bd1a30fd457f0a9f207505aa22ddb0f9a0..7d82f62b93d9510c72f6c417ee1b55e87826ad3d 100644 (file)
@@ -90,7 +90,7 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
       })
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     this.videoImportService.getMyVideoImports(this.pagination, this.sort, this.search)
         .subscribe({
           next: resultList => {
index bafb96a49d3429a1dede9e58c6fc766d52d45240..86763e8014f58240d5a91123e672e0a317d44f01 100644 (file)
@@ -5,29 +5,34 @@
   </div>
 
   <ng-container *ngIf="!signupDisabled">
-    <h1 i18n class="title-page-v2">
+    <h1 class="title-page-v2">
       <strong class="underline-orange">{{ instanceName }}</strong>
       >
-      Create an account
+      <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
     </h1>
 
     <div class="register-content">
       <my-custom-stepper linear>
 
         <cdk-step i18n-label label="About" [editable]="!signupSuccess">
-          <my-signup-step-title mascotImageName="about" i18n>
-            <strong>Create an account</strong>
-            <div>on {{ instanceName }}</div>
+          <my-signup-step-title mascotImageName="about">
+            <strong>
+              <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
+            </strong>
+
+            <div i18n>on {{ instanceName }}</div>
           </my-signup-step-title>
 
-          <my-register-step-about [videoUploadDisabled]="videoUploadDisabled"></my-register-step-about>
+          <my-register-step-about [requiresApproval]="requiresApproval" [videoUploadDisabled]="videoUploadDisabled"></my-register-step-about>
 
           <div class="step-buttons">
             <a i18n class="skip-step underline-orange" routerLink="/login">
               <strong>I already have an account</strong>, I log in
             </a>
 
-            <button i18n cdkStepperNext>Create an account</button>
+            <button cdkStepperNext>
+              <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
+            </button>
           </div>
         </cdk-step>
 
@@ -44,8 +49,8 @@
           ></my-instance-about-accordion>
 
           <my-register-step-terms
-            [hasCodeOfConduct]="!!aboutHtml.codeOfConduct"
-            [minimumAge]="minimumAge"
+            [hasCodeOfConduct]="!!aboutHtml.codeOfConduct" [minimumAge]="minimumAge" [instanceName]="instanceName"
+            [requiresApproval]="requiresApproval"
             (formBuilt)="onTermsFormBuilt($event)" (termsClick)="onTermsClick()" (codeOfConductClick)="onCodeOfConductClick()"
           ></my-register-step-terms>
 
               <div class="skip-step-description" i18n>You will be able to create a channel later</div>
             </div>
 
-            <button cdkStepperNext [disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()" (click)="signup()" i18n>
-              Create my account
+            <button cdkStepperNext [disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()" (click)="signup()">
+              <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
             </button>
           </div>
         </cdk-step>
 
         <cdk-step #lastStep i18n-label label="Done!" [editable]="false">
-          <div *ngIf="!signupSuccess && !signupError" class="done-loader">
+          <!-- Account creation can be a little bit long so display a loader  -->
+          <div *ngIf="!requiresApproval && !signupSuccess && !signupError" class="done-loader">
             <my-loader [loading]="true"></my-loader>
 
             <div i18n>PeerTube is creating your account...</div>
 
           <div *ngIf="signupError" class="alert alert-danger">{{ signupError }}</div>
 
-          <my-signup-success *ngIf="signupSuccess" [requiresEmailVerification]="requiresEmailVerification"></my-signup-success>
+          <my-signup-success-before-email
+            *ngIf="signupSuccess"
+            [requiresEmailVerification]="requiresEmailVerification" [requiresApproval]="requiresApproval" [instanceName]="instanceName"
+          ></my-signup-success-before-email>
 
           <div *ngIf="signupError" class="steps-button">
             <button cdkStepperPrevious>{{ defaultPreviousStepButtonLabel }}</button>
index 958770ebf32495a64ab0f66fd84646985d1f77b7..9259d902c3d3392eac10200486666244525c1af3 100644 (file)
@@ -5,10 +5,10 @@ import { ActivatedRoute } from '@angular/router'
 import { AuthService } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
 import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
-import { UserSignupService } from '@app/shared/shared-users'
 import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap'
 import { UserRegister } from '@shared/models'
 import { ServerConfig } from '@shared/models/server'
+import { SignupService } from '../shared/signup.service'
 
 @Component({
   selector: 'my-register',
@@ -53,7 +53,7 @@ export class RegisterComponent implements OnInit {
   constructor (
     private route: ActivatedRoute,
     private authService: AuthService,
-    private userSignupService: UserSignupService,
+    private signupService: SignupService,
     private hooks: HooksService
   ) { }
 
@@ -61,6 +61,10 @@ export class RegisterComponent implements OnInit {
     return this.serverConfig.signup.requiresEmailVerification
   }
 
+  get requiresApproval () {
+    return this.serverConfig.signup.requiresApproval
+  }
+
   get minimumAge () {
     return this.serverConfig.signup.minimumAge
   }
@@ -132,42 +136,49 @@ export class RegisterComponent implements OnInit {
   skipChannelCreation () {
     this.formStepChannel.reset()
     this.lastStep.select()
+
     this.signup()
   }
 
   async signup () {
     this.signupError = undefined
 
-    const body: UserRegister = await this.hooks.wrapObject(
+    const termsForm = this.formStepTerms.value
+    const userForm = this.formStepUser.value
+    const channelForm = this.formStepChannel?.value
+
+    const channel = this.formStepChannel?.value?.name
+      ? { name: channelForm?.name, displayName: channelForm?.displayName }
+      : undefined
+
+    const body = await this.hooks.wrapObject(
       {
-        ...this.formStepUser.value,
+        username: userForm.username,
+        password: userForm.password,
+        email: userForm.email,
+        displayName: userForm.displayName,
+
+        registrationReason: termsForm.registrationReason,
 
-        channel: this.formStepChannel?.value?.name
-          ? this.formStepChannel.value
-          : undefined
+        channel
       },
       'signup',
       'filter:api.signup.registration.create.params'
     )
 
-    this.userSignupService.signup(body).subscribe({
+    const obs = this.requiresApproval
+      ? this.signupService.requestSignup(body)
+      : this.signupService.directSignup(body)
+
+    obs.subscribe({
       next: () => {
-        if (this.requiresEmailVerification) {
+        if (this.requiresEmailVerification || this.requiresApproval) {
           this.signupSuccess = true
           return
         }
 
         // Auto login
-        this.authService.login({ username: body.username, password: body.password })
-          .subscribe({
-            next: () => {
-              this.signupSuccess = true
-            },
-
-            error: err => {
-              this.signupError = err.message
-            }
-          })
+        this.autoLogin(body)
       },
 
       error: err => {
@@ -175,4 +186,17 @@ export class RegisterComponent implements OnInit {
       }
     })
   }
+
+  private autoLogin (body: UserRegister) {
+    this.authService.login({ username: body.username, password: body.password })
+      .subscribe({
+        next: () => {
+          this.signupSuccess = true
+        },
+
+        error: err => {
+          this.signupError = err.message
+        }
+      })
+  }
 }
diff --git a/client/src/app/+signup/+register/shared/index.ts b/client/src/app/+signup/+register/shared/index.ts
new file mode 100644 (file)
index 0000000..affb54b
--- /dev/null
@@ -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 (file)
index 0000000..f14803b
--- /dev/null
@@ -0,0 +1,18 @@
+import { Validators } from '@angular/forms'
+import { BuildFormValidator } from '@app/shared/form-validators'
+
+export const REGISTER_TERMS_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ Validators.requiredTrue ],
+  MESSAGES: {
+    required: $localize`You must agree with the instance terms in order to register on it.`
+  }
+}
+
+export const REGISTER_REASON_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
+  MESSAGES: {
+    required: $localize`Registration reason is required.`,
+    minlength: $localize`Registration reason must be at least 2 characters long.`,
+    maxlength: $localize`Registration reason cannot be more than 3000 characters long.`
+  }
+}
index 769fe312726495f8092c5f7a645e1b6e17ec534d..580e8a92c6967bc787fb2eddfe250ce2c1399706 100644 (file)
     <li i18n>Have access to your <strong>watch history</strong></li>
     <li *ngIf="!videoUploadDisabled" i18n>Create your channel to <strong>publish videos</strong></li>
   </ul>
+
+  <p *ngIf="requiresApproval" i18n>
+    Moderators of {{ instanceName }} will have to approve your registration request once you have finished to fill the form.
+  </p>
 </div>
 
 <div>
index 9a09410167b4f4e5c22ee4dea58c6843c6108259..b176ffa5907e1033260015507ee2861130bcf34d 100644 (file)
@@ -7,6 +7,7 @@ import { ServerService } from '@app/core'
   styleUrls: [ './register-step-about.component.scss' ]
 })
 export class RegisterStepAboutComponent {
+  @Input() requiresApproval: boolean
   @Input() videoUploadDisabled: boolean
 
   constructor (private serverService: ServerService) {
index df92c514554b30dbc666d888d375a3077cea1674..478ca01774c1fe7dc899f3947a11949e43074491 100644 (file)
@@ -2,9 +2,9 @@ import { concat, of } from 'rxjs'
 import { pairwise } from 'rxjs/operators'
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { FormGroup } from '@angular/forms'
+import { SignupService } from '@app/+signup/shared/signup.service'
 import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
-import { UserSignupService } from '@app/shared/shared-users'
 
 @Component({
   selector: 'my-register-step-channel',
@@ -20,7 +20,7 @@ export class RegisterStepChannelComponent extends FormReactive implements OnInit
 
   constructor (
     protected formReactiveService: FormReactiveService,
-    private userSignupService: UserSignupService
+    private signupService: SignupService
   ) {
     super()
   }
@@ -51,7 +51,7 @@ export class RegisterStepChannelComponent extends FormReactive implements OnInit
   private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
     const name = this.form.value['name'] || ''
 
-    const newName = this.userSignupService.getNewUsername(oldDisplayName, newDisplayName, name)
+    const newName = this.signupService.getNewUsername(oldDisplayName, newDisplayName, name)
     this.form.patchValue({ name: newName })
   }
 }
index cbfb325184a5279c69e09484b8d5a906013693db..1d753a3f2d529a9ab5155730c3992dee92874839 100644 (file)
@@ -1,4 +1,16 @@
 <form role="form" [formGroup]="form">
+
+  <div *ngIf="requiresApproval" class="form-group">
+    <label i18n for="registrationReason">Why do you want to join {{ instanceName }}?</label>
+
+    <textarea
+      id="registrationReason" formControlName="registrationReason" class="form-control" rows="4"
+      [ngClass]="{ 'input-error': formErrors['registrationReason'] }"
+    ></textarea>
+
+    <div *ngIf="formErrors.registrationReason" class="form-error">{{ formErrors.registrationReason }}</div>
+  </div>
+
   <div class="form-group">
     <my-peertube-checkbox inputName="terms" formControlName="terms">
       <ng-template ptTemplate="label">
@@ -6,7 +18,7 @@
           I am at least {{ minimumAge }} years old and agree
           to the <a class="link-orange" (click)="onTermsClick($event)" href='#'>Terms</a>
           <ng-container *ngIf="hasCodeOfConduct"> and to the <a class="link-orange" (click)="onCodeOfConductClick($event)" href='#'>Code of Conduct</a></ng-container>
-          of this instance
+          of {{ instanceName }}
         </ng-container>
       </ng-template>
     </my-peertube-checkbox>
index 2df963b30d86dce101562716e169b125caf4ab7c..1b1fb49eebeff2705a6494ce7596909ed3d52629 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { FormGroup } from '@angular/forms'
-import { USER_TERMS_VALIDATOR } from '@app/shared/form-validators/user-validators'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
+import { REGISTER_REASON_VALIDATOR, REGISTER_TERMS_VALIDATOR } from '../shared'
 
 @Component({
   selector: 'my-register-step-terms',
@@ -10,7 +10,9 @@ import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 })
 export class RegisterStepTermsComponent extends FormReactive implements OnInit {
   @Input() hasCodeOfConduct = false
+  @Input() requiresApproval: boolean
   @Input() minimumAge = 16
+  @Input() instanceName: string
 
   @Output() formBuilt = new EventEmitter<FormGroup>()
   @Output() termsClick = new EventEmitter<void>()
@@ -28,7 +30,11 @@ export class RegisterStepTermsComponent extends FormReactive implements OnInit {
 
   ngOnInit () {
     this.buildForm({
-      terms: USER_TERMS_VALIDATOR
+      terms: REGISTER_TERMS_VALIDATOR,
+
+      registrationReason: this.requiresApproval
+        ? REGISTER_REASON_VALIDATOR
+        : null
     })
 
     setTimeout(() => this.formBuilt.emit(this.form))
index 822f8f5c5fd40af061d66001fb81646b55f51393..0a5d2e43703c7f160855dcdc8c1d4fb4be33245b 100644 (file)
@@ -2,6 +2,7 @@ import { concat, of } from 'rxjs'
 import { pairwise } from 'rxjs/operators'
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { FormGroup } from '@angular/forms'
+import { SignupService } from '@app/+signup/shared/signup.service'
 import {
   USER_DISPLAY_NAME_REQUIRED_VALIDATOR,
   USER_EMAIL_VALIDATOR,
@@ -9,7 +10,6 @@ import {
   USER_USERNAME_VALIDATOR
 } from '@app/shared/form-validators/user-validators'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
-import { UserSignupService } from '@app/shared/shared-users'
 
 @Component({
   selector: 'my-register-step-user',
@@ -24,7 +24,7 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
 
   constructor (
     protected formReactiveService: FormReactiveService,
-    private userSignupService: UserSignupService
+    private signupService: SignupService
   ) {
     super()
   }
@@ -57,7 +57,7 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
   private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
     const username = this.form.value['username'] || ''
 
-    const newUsername = this.userSignupService.getNewUsername(oldDisplayName, newDisplayName, username)
+    const newUsername = this.signupService.getNewUsername(oldDisplayName, newDisplayName, username)
     this.form.patchValue({ username: newUsername })
   }
 }
index 06905f678991336845aaa3046cd737d39dcbe3e8..75b599e0e8534eb2f74662a676e69c1442968d26 100644 (file)
@@ -1,8 +1,8 @@
 import { Component, OnInit } from '@angular/core'
+import { SignupService } from '@app/+signup/shared/signup.service'
 import { Notifier, RedirectService, ServerService } from '@app/core'
 import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
-import { UserSignupService } from '@app/shared/shared-users'
 
 @Component({
   selector: 'my-verify-account-ask-send-email',
@@ -15,7 +15,7 @@ export class VerifyAccountAskSendEmailComponent extends FormReactive implements
 
   constructor (
     protected formReactiveService: FormReactiveService,
-    private userSignupService: UserSignupService,
+    private signupService: SignupService,
     private serverService: ServerService,
     private notifier: Notifier,
     private redirectService: RedirectService
@@ -34,7 +34,7 @@ export class VerifyAccountAskSendEmailComponent extends FormReactive implements
 
   askSendVerifyEmail () {
     const email = this.form.value['verify-email-email']
-    this.userSignupService.askSendVerifyEmail(email)
+    this.signupService.askSendVerifyEmail(email)
       .subscribe({
         next: () => {
           this.notifier.success($localize`An email with verification link will be sent to ${email}.`)
index 122f3c28cecc64a98ac6d20a3fc711748c6ea3f1..8c8b1098e24bae18b4ce1b58e3094f54e9b6673f 100644 (file)
@@ -1,14 +1,19 @@
-<div class="margin-content">
-  <h1 i18n class="title-page">Verify account email confirmation</h1>
+<div *ngIf="loaded" class="margin-content">
+  <h1 i18n class="title-page">Verify email</h1>
 
-  <my-signup-success i18n *ngIf="!isPendingEmail && success" [requiresEmailVerification]="false">
-  </my-signup-success>
+  <my-signup-success-after-email
+    *ngIf="displaySignupSuccess()"
+    [requiresApproval]="isRegistrationRequest() && requiresApproval"
+  >
+  </my-signup-success-after-email>
 
-  <div i18n class="alert alert-success" *ngIf="isPendingEmail && success">Email updated.</div>
+  <div i18n class="alert alert-success" *ngIf="!isRegistrationRequest() && isPendingEmail && success">Email updated.</div>
 
   <div class="alert alert-danger" *ngIf="failed">
     <span i18n>An error occurred.</span>
 
-    <a i18n class="ms-1 link-orange" routerLink="/verify-account/ask-send-email" [queryParams]="{ isPendingEmail: isPendingEmail }">Request new verification email</a>
+    <a i18n class="ms-1 link-orange" routerLink="/verify-account/ask-send-email">
+      Request a new verification email
+    </a>
   </div>
 </div>
index 88efce4a1b75a56cea606681d30c03f8decf1dd6..faf66339110ad1bd13a19d6b7e06651de6bc51e2 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, OnInit } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
-import { AuthService, Notifier } from '@app/core'
-import { UserSignupService } from '@app/shared/shared-users'
+import { SignupService } from '@app/+signup/shared/signup.service'
+import { AuthService, Notifier, ServerService } from '@app/core'
 
 @Component({
   selector: 'my-verify-account-email',
@@ -13,32 +13,82 @@ export class VerifyAccountEmailComponent implements OnInit {
   failed = false
   isPendingEmail = false
 
+  requiresApproval: boolean
+  loaded = false
+
   private userId: number
+  private registrationId: number
   private verificationString: string
 
   constructor (
-    private userSignupService: UserSignupService,
+    private signupService: SignupService,
+    private server: ServerService,
     private authService: AuthService,
     private notifier: Notifier,
     private route: ActivatedRoute
   ) {
   }
 
+  get instanceName () {
+    return this.server.getHTMLConfig().instance.name
+  }
+
   ngOnInit () {
     const queryParams = this.route.snapshot.queryParams
+
+    this.server.getConfig().subscribe(config => {
+      this.requiresApproval = config.signup.requiresApproval
+
+      this.loaded = true
+    })
+
     this.userId = queryParams['userId']
+    this.registrationId = queryParams['registrationId']
+
     this.verificationString = queryParams['verificationString']
+
     this.isPendingEmail = queryParams['isPendingEmail'] === 'true'
 
-    if (!this.userId || !this.verificationString) {
-      this.notifier.error($localize`Unable to find user id or verification string.`)
-    } else {
-      this.verifyEmail()
+    if (!this.verificationString) {
+      this.notifier.error($localize`Unable to find verification string in URL query.`)
+      return
+    }
+
+    if (!this.userId && !this.registrationId) {
+      this.notifier.error($localize`Unable to find user id or registration id in URL query.`)
+      return
     }
+
+    this.verifyEmail()
+  }
+
+  isRegistrationRequest () {
+    return !!this.registrationId
+  }
+
+  displaySignupSuccess () {
+    if (!this.success) return false
+    if (!this.isRegistrationRequest() && this.isPendingEmail) return false
+
+    return true
   }
 
   verifyEmail () {
-    this.userSignupService.verifyEmail(this.userId, this.verificationString, this.isPendingEmail)
+    if (this.isRegistrationRequest()) {
+      return this.verifyRegistrationEmail()
+    }
+
+    return this.verifyUserEmail()
+  }
+
+  private verifyUserEmail () {
+    const options = {
+      userId: this.userId,
+      verificationString: this.verificationString,
+      isPendingEmail: this.isPendingEmail
+    }
+
+    this.signupService.verifyUserEmail(options)
       .subscribe({
         next: () => {
           if (this.authService.isLoggedIn()) {
@@ -55,4 +105,24 @@ export class VerifyAccountEmailComponent implements OnInit {
         }
       })
   }
+
+  private verifyRegistrationEmail () {
+    const options = {
+      registrationId: this.registrationId,
+      verificationString: this.verificationString
+    }
+
+    this.signupService.verifyRegistrationEmail(options)
+      .subscribe({
+        next: () => {
+          this.success = true
+        },
+
+        error: err => {
+          this.failed = true
+
+          this.notifier.error(err.message)
+        }
+      })
+  }
 }
index 0aa08f3e2e82de07a77fb8917e01d81dff81366c..0600f0af85cf459173e5c14963604b3a7c61b053 100644 (file)
@@ -5,7 +5,9 @@ import { SharedMainModule } from '@app/shared/shared-main'
 import { SharedUsersModule } from '@app/shared/shared-users'
 import { SignupMascotComponent } from './signup-mascot.component'
 import { SignupStepTitleComponent } from './signup-step-title.component'
-import { SignupSuccessComponent } from './signup-success.component'
+import { SignupSuccessBeforeEmailComponent } from './signup-success-before-email.component'
+import { SignupSuccessAfterEmailComponent } from './signup-success-after-email.component'
+import { SignupService } from './signup.service'
 
 @NgModule({
   imports: [
@@ -16,7 +18,8 @@ import { SignupSuccessComponent } from './signup-success.component'
   ],
 
   declarations: [
-    SignupSuccessComponent,
+    SignupSuccessBeforeEmailComponent,
+    SignupSuccessAfterEmailComponent,
     SignupStepTitleComponent,
     SignupMascotComponent
   ],
@@ -26,12 +29,14 @@ import { SignupSuccessComponent } from './signup-success.component'
     SharedFormModule,
     SharedGlobalIconModule,
 
-    SignupSuccessComponent,
+    SignupSuccessBeforeEmailComponent,
+    SignupSuccessAfterEmailComponent,
     SignupStepTitleComponent,
     SignupMascotComponent
   ],
 
   providers: [
+    SignupService
   ]
 })
 export 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 (file)
index 0000000..1c3536a
--- /dev/null
@@ -0,0 +1,21 @@
+<my-signup-step-title mascotImageName="success">
+  <strong i18n>Email verified!</strong>
+</my-signup-step-title>
+
+<div class="alert pt-alert-primary">
+  <ng-container *ngIf="requiresApproval">
+    <p i18n>Your email has been verified and your account request has been sent!</p>
+
+    <p i18n>
+      A moderator will check your registration request soon and you'll receive an email when it will be accepted or rejected.
+    </p>
+  </ng-container>
+
+  <ng-container *ngIf="!requiresApproval">
+    <p i18n>Your email has been verified and your account has been created!</p>
+
+    <p i18n>
+      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>.
+    </p>
+  </ng-container>
+</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 (file)
index 0000000..3d72fda
--- /dev/null
@@ -0,0 +1,10 @@
+import { Component, Input } from '@angular/core'
+
+@Component({
+  selector: 'my-signup-success-after-email',
+  templateUrl: './signup-success-after-email.component.html',
+  styleUrls: [ './signup-success.component.scss' ]
+})
+export class SignupSuccessAfterEmailComponent {
+  @Input() requiresApproval: boolean
+}
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 (file)
index 0000000..b9668ee
--- /dev/null
@@ -0,0 +1,35 @@
+<my-signup-step-title mascotImageName="success">
+  <ng-container *ngIf="requiresApproval">
+    <strong i18n>Account request sent</strong>
+  </ng-container>
+
+  <ng-container *ngIf="!requiresApproval" i18n>
+    <strong>Welcome</strong>
+    <div>on {{ instanceName }}</div>
+  </ng-container>
+</my-signup-step-title>
+
+<div class="alert pt-alert-primary">
+  <p *ngIf="requiresApproval" i18n>Your account request has been sent!</p>
+  <p *ngIf="!requiresApproval" i18n>Your account has been created!</p>
+
+  <ng-container *ngIf="requiresEmailVerification">
+    <p i18n *ngIf="requiresApproval">
+      <strong>Check your emails</strong> to validate your account and complete your registration request.
+    </p>
+
+    <p i18n *ngIf="!requiresApproval">
+      <strong>Check your emails</strong> to validate your account and complete your registration.
+    </p>
+  </ng-container>
+
+  <ng-container *ngIf="!requiresEmailVerification">
+    <p i18n *ngIf="requiresApproval">
+      A moderator will check your registration request soon and you'll receive an email when it will be accepted or rejected.
+    </p>
+
+    <p *ngIf="!requiresApproval" i18n>
+      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>.
+    </p>
+  </ng-container>
+</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 (file)
index 0000000..d724623
--- /dev/null
@@ -0,0 +1,12 @@
+import { Component, Input } from '@angular/core'
+
+@Component({
+  selector: 'my-signup-success-before-email',
+  templateUrl: './signup-success-before-email.component.html',
+  styleUrls: [ './signup-success.component.scss' ]
+})
+export class SignupSuccessBeforeEmailComponent {
+  @Input() requiresApproval: boolean
+  @Input() requiresEmailVerification: boolean
+  @Input() instanceName: string
+}
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 (file)
index c14889c..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-<my-signup-step-title mascotImageName="success" i18n>
-  <strong>Welcome</strong>
-  <div>on {{ instanceName }}</div>
-</my-signup-step-title>
-
-<div class="alert pt-alert-primary">
-  <p i18n>Your account has been created!</p>
-
-  <p i18n *ngIf="requiresEmailVerification">
-    <strong>Check your emails</strong> to validate your account and complete your inscription.
-  </p>
-
-  <ng-container *ngIf="!requiresEmailVerification">
-    <p i18n>
-      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>.
-    </p>
-
-    <p i18n>
-      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>.
-    </p>
-  </ng-container>
-</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 (file)
index a03f381..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Component, Input } from '@angular/core'
-import { ServerService } from '@app/core'
-
-@Component({
-  selector: 'my-signup-success',
-  templateUrl: './signup-success.component.html',
-  styleUrls: [ './signup-success.component.scss' ]
-})
-export class SignupSuccessComponent {
-  @Input() requiresEmailVerification: boolean
-
-  constructor (private serverService: ServerService) {
-
-  }
-
-  get instanceName () {
-    return this.serverService.getHTMLConfig().instance.name
-  }
-}
similarity index 53%
rename from client/src/app/shared/shared-users/user-signup.service.ts
rename to client/src/app/+signup/shared/signup.service.ts
index 46fe34af19472bb6052e53e258a018f894690dfb..f647298be47cead9766f4ca442e725735e0102e3 100644 (file)
@@ -2,17 +2,18 @@ import { catchError, tap } from 'rxjs/operators'
 import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { RestExtractor, UserService } from '@app/core'
-import { UserRegister } from '@shared/models'
+import { UserRegister, UserRegistrationRequest } from '@shared/models'
 
 @Injectable()
-export class UserSignupService {
+export class SignupService {
+
   constructor (
     private authHttp: HttpClient,
     private restExtractor: RestExtractor,
     private userService: UserService
   ) { }
 
-  signup (userCreate: UserRegister) {
+  directSignup (userCreate: UserRegister) {
     return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
                .pipe(
                  tap(() => this.userService.setSignupInThisSession(true)),
@@ -20,8 +21,21 @@ export class UserSignupService {
                )
   }
 
-  verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) {
-    const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email`
+  requestSignup (userCreate: UserRegistrationRequest) {
+    return this.authHttp.post(UserService.BASE_USERS_URL + 'registrations/request', userCreate)
+               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  // ---------------------------------------------------------------------------
+
+  verifyUserEmail (options: {
+    userId: number
+    verificationString: string
+    isPendingEmail: boolean
+  }) {
+    const { userId, verificationString, isPendingEmail } = options
+
+    const url = `${UserService.BASE_USERS_URL}${userId}/verify-email`
     const body = {
       verificationString,
       isPendingEmail
@@ -31,13 +45,28 @@ export class UserSignupService {
                .pipe(catchError(res => this.restExtractor.handleError(res)))
   }
 
+  verifyRegistrationEmail (options: {
+    registrationId: number
+    verificationString: string
+  }) {
+    const { registrationId, verificationString } = options
+
+    const url = `${UserService.BASE_USERS_URL}registrations/${registrationId}/verify-email`
+    const body = { verificationString }
+
+    return this.authHttp.post(url, body)
+               .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
   askSendVerifyEmail (email: string) {
-    const url = UserService.BASE_USERS_URL + '/ask-send-verify-email'
+    const url = UserService.BASE_USERS_URL + 'ask-send-verify-email'
 
     return this.authHttp.post(url, { email })
                .pipe(catchError(err => this.restExtractor.handleError(err)))
   }
 
+  // ---------------------------------------------------------------------------
+
   getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {
     // Don't update display name, the user seems to have changed it
     if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername
index 94853423b085313c006dafa9c8ac779fd8766576..84548de97f8b9983e06a8d5b7f92210765946f3c 100644 (file)
@@ -133,8 +133,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     this.loadRouteParams()
     this.loadRouteQuery()
 
-    this.initHotkeys()
-
     this.theaterEnabled = getStoredTheater()
 
     this.hooks.runAction('action:video-watch.init', 'video-watch')
@@ -295,6 +293,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
           subtitle: queryParams.subtitle,
 
           playerMode: queryParams.mode,
+          playbackRate: queryParams.playbackRate,
           peertubeLink: false
         }
 
@@ -406,6 +405,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       if (res === false) return this.location.back()
     }
 
+    this.buildHotkeysHelp(video)
+
     this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay })
       .catch(err => logger.error('Cannot build the player', err))
 
@@ -657,6 +658,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
         muted: urlOptions.muted,
         loop: urlOptions.loop,
         subtitle: urlOptions.subtitle,
+        playbackRate: urlOptions.playbackRate,
 
         peertubeLink: urlOptions.peertubeLink,
 
@@ -785,33 +787,43 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     this.video.viewers = newViewers
   }
 
-  private initHotkeys () {
+  private buildHotkeysHelp (video: Video) {
+    if (this.hotkeys.length !== 0) {
+      this.hotkeysService.remove(this.hotkeys)
+    }
+
     this.hotkeys = [
       // These hotkeys are managed by the player
       new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen`),
       new Hotkey('space', e => e, undefined, $localize`Play/Pause the video`),
       new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video`),
 
-      new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
-
       new Hotkey('up', e => e, undefined, $localize`Increase the volume`),
       new Hotkey('down', e => e, undefined, $localize`Decrease the volume`),
 
-      new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
-      new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
-
-      new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
-      new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
-
-      new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
-      new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`),
-
       new Hotkey('t', e => {
         this.theaterEnabled = !this.theaterEnabled
         return false
       }, undefined, $localize`Toggle theater mode`)
     ]
 
+    if (!video.isLive) {
+      this.hotkeys = this.hotkeys.concat([
+        // These hotkeys are also managed by the player but only for VOD
+
+        new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
+
+        new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
+        new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
+
+        new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
+        new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
+
+        new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
+        new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`)
+      ])
+    }
+
     if (this.isUserLoggedIn()) {
       this.hotkeys = this.hotkeys.concat([
         new Hotkey('shift+s', () => {
index c8fa8ef302c59bc9d0f68a4a67ec8ccd6ce9752b..bafe30fd78953bc7bee2cfb8b18d84994fac3949 100644 (file)
@@ -177,6 +177,9 @@ export class VideosListCommonPageComponent implements OnInit, OnDestroy, Disable
       case 'best':
         return '-hot'
 
+      case 'name':
+        return 'name'
+
       default:
         return '-' + algorithm as VideoSortField
     }
index 4de28e51e9d95ee53c969b882e53065a43e617ca..ed7eabb76c5442dae30e1379b9b04d96b24603e7 100644 (file)
@@ -5,10 +5,11 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular
 import { Injectable } from '@angular/core'
 import { Router } from '@angular/router'
 import { Notifier } from '@app/core/notification/notifier.service'
-import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index'
+import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage, PluginsManager } from '@root-helpers/index'
 import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models'
 import { environment } from '../../../environments/environment'
 import { RestExtractor } from '../rest/rest-extractor.service'
+import { ServerService } from '../server'
 import { AuthStatus } from './auth-status.model'
 import { AuthUser } from './auth-user.model'
 
@@ -44,6 +45,7 @@ export class AuthService {
   private refreshingTokenObservable: Observable<any>
 
   constructor (
+    private serverService: ServerService,
     private http: HttpClient,
     private notifier: Notifier,
     private hotkeysService: HotkeysService,
@@ -213,25 +215,28 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
     const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
 
     this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers })
-                                         .pipe(
-                                           map(res => this.handleRefreshToken(res)),
-                                           tap(() => {
-                                             this.refreshingTokenObservable = null
-                                           }),
-                                           catchError(err => {
-                                             this.refreshingTokenObservable = null
-
-                                             logger.error(err)
-                                             logger.info('Cannot refresh token -> logout...')
-                                             this.logout()
-                                             this.router.navigate([ '/login' ])
-
-                                             return observableThrowError(() => ({
-                                               error: $localize`You need to reconnect.`
-                                             }))
-                                           }),
-                                           share()
-                                         )
+      .pipe(
+        map(res => this.handleRefreshToken(res)),
+        tap(() => {
+          this.refreshingTokenObservable = null
+        }),
+        catchError(err => {
+          this.refreshingTokenObservable = null
+
+          logger.error(err)
+          logger.info('Cannot refresh token -> logout...')
+          this.logout()
+
+          const externalLoginUrl = PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverService.getHTMLConfig())
+          if (externalLoginUrl) window.location.href = externalLoginUrl
+          else this.router.navigate([ '/login' ])
+
+          return observableThrowError(() => ({
+            error: $localize`You need to reconnect.`
+          }))
+        }),
+        share()
+      )
 
     return this.refreshingTokenObservable
   }
index 78df92cc9e3274afd2042a170431a7d07e715ddc..d99591d6c630497cae8bf01d0979a6bfbfa36b1d 100644 (file)
@@ -15,7 +15,7 @@ export class LinkifierService {
     },
     formatHref: {
       mention: (href: string) => {
-        return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + href.substr(1)
+        return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + href.substring(1)
       }
     }
   }
index a5fd7286294bad2c721d5365ece57423b250d8cf..dd23a1b01bc2a9de7f58c1e07bb9e2438e6034bf 100644 (file)
@@ -64,8 +64,8 @@ export class MarkdownService {
 
   textMarkdownToHTML (options: {
     markdown: string
-    withHtml?: boolean
-    withEmoji?: boolean
+    withHtml?: boolean // default false
+    withEmoji?: boolean // default false
   }) {
     const { markdown, withHtml = false, withEmoji = false } = options
 
@@ -76,8 +76,8 @@ export class MarkdownService {
 
   enhancedMarkdownToHTML (options: {
     markdown: string
-    withHtml?: boolean
-    withEmoji?: boolean
+    withHtml?: boolean // default false
+    withEmoji?: boolean // default false
   }) {
     const { markdown, withHtml = false, withEmoji = false } = options
 
@@ -99,6 +99,8 @@ export class MarkdownService {
     return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags })
   }
 
+  // ---------------------------------------------------------------------------
+
   processVideoTimestamps (videoShortUUID: string, html: string) {
     return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
       const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
index de3f2bfff7f0cb64dd30d5db41ed7e45e4f73232..daed7f1785aa9e10a9f6853fcf6409f9d032e5f0 100644 (file)
@@ -87,7 +87,11 @@ export class RestExtractor {
 
     if (err.status !== undefined) {
       const errorMessage = this.buildServerErrorMessage(err)
-      logger.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
+
+      const message = `Backend returned code ${err.status}, errorMessage is: ${errorMessage}`
+
+      if (err.status === HttpStatusCode.NOT_FOUND_404) logger.clientError(message)
+      else logger.error(message)
 
       return errorMessage
     }
index ec5646b5df6320161d53ef7ee94dfb9d3a0369dd..707110d7f7f404aeacaea73f3e8647fae0202360 100644 (file)
@@ -7,7 +7,7 @@ import { RestPagination } from './rest-pagination'
 
 const debugLogger = debug('peertube:tables:RestTable')
 
-export abstract class RestTable {
+export abstract class RestTable <T = unknown> {
 
   abstract totalRecords: number
   abstract sort: SortMeta
@@ -17,6 +17,8 @@ export abstract class RestTable {
   rowsPerPage = this.rowsPerPageOptions[0]
   expandedRows = {}
 
+  selectedRows: T[] = []
+
   search: string
 
   protected route: ActivatedRoute
@@ -75,7 +77,17 @@ export abstract class RestTable {
     this.reloadData()
   }
 
-  protected abstract reloadData (): void
+  isInSelectionMode () {
+    return this.selectedRows.length !== 0
+  }
+
+  protected abstract reloadDataInternal (): void
+
+  protected reloadData () {
+    this.selectedRows = []
+
+    this.reloadDataInternal()
+  }
 
   private getSortLocalStorageKey () {
     return 'rest-table-sort-' + this.getIdentifier()
index c5d08ab75a8037d2e867d42f08225396648d3596..15b1a3c4a4b18bb9110bebe8660f07e2be72ea79 100644 (file)
         <a i18n *ngIf="!getExternalLoginHref()" routerLink="/login" class="peertube-button-link orange-button">Login</a>
         <a i18n *ngIf="getExternalLoginHref()" [href]="getExternalLoginHref()" class="peertube-button-link orange-button">Login</a>
 
-        <a i18n *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button">Create an account</a>
+        <a *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button">
+          <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
+        </a>
       </div>
 
       <ng-container *ngFor="let menuSection of menuSections" >
index 63f01df92f560b891a8b10bbbc4feab2b6bde463..fc6d74cff105594aa28b0feb9259bcd0fd8e2c5e 100644 (file)
@@ -1,6 +1,7 @@
 import { HotkeysService } from 'angular2-hotkeys'
 import * as debug from 'debug'
 import { switchMap } from 'rxjs/operators'
+import { environment } from 'src/environments/environment'
 import { ViewportScroller } from '@angular/common'
 import { Component, OnInit, ViewChild } from '@angular/core'
 import { Router } from '@angular/router'
@@ -91,6 +92,10 @@ export class MenuComponent implements OnInit {
     return this.languageChooserModal.getCurrentLanguage()
   }
 
+  get requiresApproval () {
+    return this.serverConfig.signup.requiresApproval
+  }
+
   ngOnInit () {
     this.htmlServerConfig = this.serverService.getHTMLConfig()
     this.currentInterfaceLanguage = this.languageChooserModal.getCurrentLanguage()
@@ -131,12 +136,7 @@ export class MenuComponent implements OnInit {
   }
 
   getExternalLoginHref () {
-    if (!this.serverConfig || this.serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
-
-    const externalAuths = this.serverConfig.plugin.registeredExternalAuths
-    if (externalAuths.length !== 1) return undefined
-
-    return PluginsManager.getExternalAuthHref(externalAuths[0])
+    return PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverConfig)
   }
 
   isRegistrationAllowed () {
index 31c253b9b61e317e685872896d056573e8826276..1e4bba86b55261d36bebc463c32fe7dbaeb9f2bd 100644 (file)
@@ -12,5 +12,5 @@ export type BuildFormArgument = {
 }
 
 export type BuildFormDefaultValues = {
-  [ name: string ]: number | string | string[] | BuildFormDefaultValues
+  [ name: string ]: boolean | number | string | string[] | BuildFormDefaultValues
 }
index b93de75eaf5fcc859afcae13fa478572faca4544..ed6e0582eaad8b4078742c208ca9a8ebc607de22 100644 (file)
@@ -136,13 +136,6 @@ export const USER_DESCRIPTION_VALIDATOR: BuildFormValidator = {
   }
 }
 
-export const USER_TERMS_VALIDATOR: BuildFormValidator = {
-  VALIDATORS: [ Validators.requiredTrue ],
-  MESSAGES: {
-    required: $localize`You must agree with the instance terms in order to register on it.`
-  }
-}
-
 export const USER_BAN_REASON_VALIDATOR: BuildFormValidator = {
   VALIDATORS: [
     Validators.minLength(3),
index 089be501d33b3fc65c32fd3f647ea1f4128e1d8e..2d3e26a25ff69c88255706920e602021330e1c6a 100644 (file)
@@ -8,7 +8,7 @@
 
       <span class="moderation-expanded-text">
         <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
-          class="chip"
+          class="chip me-1"
         >
           <my-actor-avatar size="18" [actor]="abuse.reporterAccount" actorType="account"></my-actor-avatar>
           <div>
@@ -29,7 +29,7 @@
       <span class="moderation-expanded-label" i18n>Reportee</span>
       <span class="moderation-expanded-text">
         <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
-          class="chip"
+          class="chip me-1"
         >
           <my-actor-avatar size="18" [actor]="abuse.flaggedAccount" actorType="account"></my-actor-avatar>
           <div>
@@ -63,7 +63,7 @@
     <div *ngIf="predefinedReasons" class="mt-2 d-flex">
       <span>
         <a *ngFor="let reason of predefinedReasons"  [routerLink]="[ '.' ]"
-          [queryParams]="{ 'search': 'tag:' + reason.id  }" class="chip rectangular bg-secondary text-light"
+          [queryParams]="{ 'search': 'tag:' + reason.id  }" class="pt-badge badge-secondary"
         >
           <div>{{ reason.label }}</div>
         </a>
index 569a37b1753deb7f7d438d0abf909b94816ef8f0..d8470e927768e9054099987b18287c1a42ffee54 100644 (file)
@@ -175,7 +175,7 @@ export class AbuseListTableComponent extends RestTable implements OnInit {
     return Actor.IS_LOCAL(abuse.reporterAccount.host)
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     debugLogger('Loading data.')
 
     const options = {
index 4e802b14dd0f20795294922dd20fc25d2645a460..b2ee2d8f28de1c72ff3ab86faa79cce54b14ba13 100644 (file)
@@ -6,9 +6,9 @@ import { CustomMarkupService } from './custom-markup.service'
   templateUrl: './custom-markup-container.component.html'
 })
 export class CustomMarkupContainerComponent implements OnChanges {
-  @ViewChild('contentWrapper') contentWrapper: ElementRef<HTMLInputElement>
+  @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef<HTMLInputElement>
 
-  @Input() content: string
+  @Input() content: string | HTMLDivElement
 
   displayed = false
 
@@ -17,17 +17,23 @@ export class CustomMarkupContainerComponent implements OnChanges {
   ) { }
 
   async ngOnChanges () {
-    await this.buildElement()
+    await this.rebuild()
   }
 
-  private async buildElement () {
-    if (!this.content) return
+  private async rebuild () {
+    if (this.content instanceof HTMLDivElement) {
+      return this.loadElement(this.content)
+    }
 
     const { rootElement, componentsLoaded } = await this.customMarkupService.buildElement(this.content)
-    this.contentWrapper.nativeElement.appendChild(rootElement)
-
     await componentsLoaded
 
+    return this.loadElement(rootElement)
+  }
+
+  private loadElement (el: HTMLDivElement) {
+    this.contentWrapper.nativeElement.appendChild(el)
+
     this.displayed = true
   }
 }
index 1af060548540231ed0c0c4821bbac996de513af5..264dd95777d3de691ff8bed86a52f942a45e0aad 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, Input } from '@angular/core'
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
 import { VideoChannel } from '../../shared-main'
 import { CustomMarkupComponent } from './shared'
 
@@ -9,7 +9,8 @@ import { CustomMarkupComponent } from './shared'
 @Component({
   selector: 'my-button-markup',
   templateUrl: 'button-markup.component.html',
-  styleUrls: [ 'button-markup.component.scss' ]
+  styleUrls: [ 'button-markup.component.scss' ],
+  changeDetection: ChangeDetectionStrategy.OnPush
 })
 export class ButtonMarkupComponent implements CustomMarkupComponent {
   @Input() theme: 'primary' | 'secondary'
index ba12b713945da2d804d5f430b7c44abc6414119b..1e7860750ec5a4cc5af3a9384b22789aa5074916 100644 (file)
@@ -1,6 +1,6 @@
 import { from } from 'rxjs'
 import { finalize, map, switchMap, tap } from 'rxjs/operators'
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { MarkdownService, Notifier, UserService } from '@app/core'
 import { FindInBulkService } from '@app/shared/shared-search'
 import { VideoSortField } from '@shared/models'
@@ -14,7 +14,8 @@ import { CustomMarkupComponent } from './shared'
 @Component({
   selector: 'my-channel-miniature-markup',
   templateUrl: 'channel-miniature-markup.component.html',
-  styleUrls: [ 'channel-miniature-markup.component.scss' ]
+  styleUrls: [ 'channel-miniature-markup.component.scss' ],
+  changeDetection: ChangeDetectionStrategy.OnPush
 })
 export class ChannelMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
   @Input() name: string
index 07fa6fd2d29cb5fe2c9d4bf93be25c994b96e70a..ab52e7e37f3dc9fba6c1d5180fcfe09a08b90768 100644 (file)
@@ -1,5 +1,5 @@
 import { finalize } from 'rxjs/operators'
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { Notifier } from '@app/core'
 import { FindInBulkService } from '@app/shared/shared-search'
 import { MiniatureDisplayOptions } from '../../shared-video-miniature'
@@ -13,7 +13,8 @@ import { CustomMarkupComponent } from './shared'
 @Component({
   selector: 'my-playlist-miniature-markup',
   templateUrl: 'playlist-miniature-markup.component.html',
-  styleUrls: [ 'playlist-miniature-markup.component.scss' ]
+  styleUrls: [ 'playlist-miniature-markup.component.scss' ],
+  changeDetection: ChangeDetectionStrategy.OnPush
 })
 export class PlaylistMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
   @Input() uuid: string
index cbbacf77c65d6833b14553c6a1424d97b158ced7..c3766635925667ff42acf0ffbaa483728e4344ff 100644 (file)
@@ -1,5 +1,5 @@
 import { finalize } from 'rxjs/operators'
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { AuthService, Notifier } from '@app/core'
 import { FindInBulkService } from '@app/shared/shared-search'
 import { Video } from '../../shared-main'
@@ -13,7 +13,8 @@ import { CustomMarkupComponent } from './shared'
 @Component({
   selector: 'my-video-miniature-markup',
   templateUrl: 'video-miniature-markup.component.html',
-  styleUrls: [ 'video-miniature-markup.component.scss' ]
+  styleUrls: [ 'video-miniature-markup.component.scss' ],
+  changeDetection: ChangeDetectionStrategy.OnPush
 })
 export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
   @Input() uuid: string
index 7d3498d4cb7a33bbddddc988ff21d3b57e32aa10..70e88ea51ce2ae690d4c8ac26b1a8dcefe30bac7 100644 (file)
@@ -1,5 +1,5 @@
 import { finalize } from 'rxjs/operators'
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { AuthService, Notifier } from '@app/core'
 import { VideoSortField } from '@shared/models'
 import { Video, VideoService } from '../../shared-main'
@@ -13,7 +13,8 @@ import { CustomMarkupComponent } from './shared'
 @Component({
   selector: 'my-videos-list-markup',
   templateUrl: 'videos-list-markup.component.html',
-  styleUrls: [ 'videos-list-markup.component.scss' ]
+  styleUrls: [ 'videos-list-markup.component.scss' ],
+  changeDetection: ChangeDetectionStrategy.OnPush
 })
 export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit {
   @Input() sort: string
index e3371f22c7592b594f58f9e7551758020fe8a12d..c6527e1696fe1ccce7d73b6b4d308f9a18dfe21e 100644 (file)
@@ -31,6 +31,8 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
   @Input() markdownType: 'text' | 'enhanced' = 'text'
   @Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
 
+  @Input() debounceTime = 150
+
   @Input() markdownVideo: Video
 
   @Input() name = 'description'
@@ -59,7 +61,7 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
   ngOnInit () {
     this.contentChanged
         .pipe(
-          debounceTime(150),
+          debounceTime(this.debounceTime),
           distinctUntilChanged()
         )
         .subscribe(() => this.updatePreviews())
index 6c05764df408ad37bc2cd336cc94f7a3d41780d0..205f2bc975a7423239914e3dfbfc4db0c689462c 100644 (file)
     </tr>
 
     <tr>
-      <th i18n class="label" scope="row">User registration allowed</th>
-      <td>
-        <my-feature-boolean [value]="serverConfig.signup.allowed"></my-feature-boolean>
-      </td>
+      <th i18n class="label" scope="row">User registration</th>
+
+      <td class="value">{{ buildRegistrationLabel() }}</td>
     </tr>
 
     <tr>
index e405c579082e896df107705ef9eabcd4f5d2451f..c3df7c594eb0bcc506db498d2b24584513a4b1a7 100644 (file)
@@ -56,6 +56,15 @@ export class InstanceFeaturesTableComponent implements OnInit {
     if (policy === 'display') return $localize`Displayed`
   }
 
+  buildRegistrationLabel () {
+    const config = this.serverConfig.signup
+
+    if (config.allowed !== true) return $localize`Disabled`
+    if (config.requiresApproval === true) return $localize`Requires approval by moderators`
+
+    return $localize`Enabled`
+  }
+
   getServerVersionAndCommit () {
     return this.serverService.getServerVersionAndCommit()
   }
index 89f47db240cbc05a333bae3be50dfaac8879ded0..f5b2e05dbf28f2e96c10b08f92d77d43ff5aebc2 100644 (file)
@@ -7,6 +7,11 @@ import { peertubeTranslate } from '@shared/core-utils/i18n'
 import { About } from '@shared/models'
 import { environment } from '../../../environments/environment'
 
+export type AboutHTML = Pick<About['instance'],
+'terms' | 'codeOfConduct' | 'moderationInformation' | 'administrator' | 'creationReason' |
+'maintenanceLifetime' | 'businessModel' | 'hardwareInformation'
+>
+
 @Injectable()
 export class InstanceService {
   private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
@@ -39,7 +44,7 @@ export class InstanceService {
   }
 
   async buildHtml (about: About) {
-    const html = {
+    const html: AboutHTML = {
       terms: '',
       codeOfConduct: '',
       moderationInformation: '',
index b80ddb9f532329b13c3ae9da047f030635368cef..dd41a5f05a3064ff6de13921375627b30e5a8b2a 100644 (file)
@@ -1,3 +1,4 @@
 export * from './account.model'
 export * from './account.service'
 export * from './actor.model'
+export * 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 (file)
index 0000000..35d6c53
--- /dev/null
@@ -0,0 +1,2 @@
+<ng-container i18n *ngIf="requiresApproval">Request an account</ng-container>
+<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 (file)
index 0000000..caacb9c
--- /dev/null
@@ -0,0 +1,9 @@
+import { Component, Input } from '@angular/core'
+
+@Component({
+  selector: 'my-signup-label',
+  templateUrl: './signup-label.component.html'
+})
+export class SignupLabelComponent {
+  @Input() requiresApproval: boolean
+}
index c1523bc5021af307a2c5c6bea32943e5d7190df1..eb1642d9750f1d1da0834865567bdfb045b6da60 100644 (file)
@@ -16,7 +16,7 @@ import {
 import { LoadingBarModule } from '@ngx-loading-bar/core'
 import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
 import { SharedGlobalIconModule } from '../shared-icons'
-import { AccountService } from './account'
+import { AccountService, SignupLabelComponent } from './account'
 import {
   AutofocusDirective,
   BytesPipe,
@@ -113,6 +113,8 @@ import { VideoChannelService } from './video-channel'
     UserQuotaComponent,
     UserNotificationsComponent,
 
+    SignupLabelComponent,
+
     EmbedComponent,
 
     PluginPlaceholderComponent,
@@ -171,6 +173,8 @@ import { VideoChannelService } from './video-channel'
     UserQuotaComponent,
     UserNotificationsComponent,
 
+    SignupLabelComponent,
+
     EmbedComponent,
 
     PluginPlaceholderComponent,
index bf8870a7965712d8f719abf51512e6b356c65d84..96e7b4dd04430a5dbac9ab4d505c9185dbb5b24e 100644 (file)
@@ -83,6 +83,11 @@ export class UserNotification implements UserNotificationServer {
     latestVersion: string
   }
 
+  registration?: {
+    id: number
+    username: string
+  }
+
   createdAt: string
   updatedAt: string
 
@@ -97,6 +102,8 @@ export class UserNotification implements UserNotificationServer {
 
   accountUrl?: string
 
+  registrationsUrl?: string
+
   videoImportIdentifier?: string
   videoImportUrl?: string
 
@@ -135,6 +142,7 @@ export class UserNotification implements UserNotificationServer {
 
       this.plugin = hash.plugin
       this.peertube = hash.peertube
+      this.registration = hash.registration
 
       this.createdAt = hash.createdAt
       this.updatedAt = hash.updatedAt
@@ -208,6 +216,10 @@ export class UserNotification implements UserNotificationServer {
           this.accountUrl = this.buildAccountUrl(this.account)
           break
 
+        case UserNotificationType.NEW_USER_REGISTRATION_REQUEST:
+          this.registrationsUrl = '/admin/moderation/registrations/list'
+          break
+
         case UserNotificationType.NEW_FOLLOW:
           this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
           break
index e7cdb0183c1ea1f9f709db5ca6cb2d6284093dd6..a51e0829297ea8e245d023b583740d2d9d33a12d 100644 (file)
         </div>
       </ng-container>
 
+      <ng-container *ngSwitchCase="20"> <!-- UserNotificationType.NEW_USER_REGISTRATION_REQUEST -->
+        <my-global-icon iconName="user-add" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          User <a (click)="markAsRead(notification)" [routerLink]="notification.registrationsUrl">{{ notification.registration.username }}</a> wants to register on your instance
+        </div>
+      </ng-container>
+
       <ng-container *ngSwitchDefault>
         <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
 
index 8b1239d3466c745e64fdc8897e7402ded6ef8389..00aaf3b9c8024d1c45cec32aa259152ed28840b5 100644 (file)
@@ -1,10 +1,6 @@
 @use '_variables' as *;
 @use '_mixins' as *;
 
-.chip {
-  @include chip;
-}
-
 .unblock-button {
   @include peertube-button;
   @include grey-button;
index 9ed00bc12f211c0cad3d4a7f560f9588c5284629..38dbbff788afa9513e0449baa8c137d132a56daf 100644 (file)
@@ -48,7 +48,7 @@ export class GenericAccountBlocklistComponent extends RestTable implements OnIni
     )
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     const operation = this.mode === BlocklistComponentType.Account
       ? this.blocklistService.getUserAccountBlocklist({
         pagination: this.pagination,
index eaf5a825026b378e05f27b84e414eb0f4de92ad4..7c1e308cfb3072aba5ad43e619e0272692a3dc2a 100644 (file)
   }
 }
 
-.chip {
-  @include chip;
-}
-
 my-action-dropdown.show {
   ::ng-deep .dropdown-root {
     display: block !important;
index e29668a23d0c88224095e05c05c9f0bfc1798837..1a6b0435f0aaa5b607c37a02b69ffd136bbef334 100644 (file)
@@ -24,7 +24,3 @@ a {
 .block-button {
   @include create-button;
 }
-
-.chip {
-  @include chip;
-}
index 1ba7a1b4d732e33dffc07092d18d93722f055f83..f1bcbd561126b6efb50eabb8ac57c74c180a6927 100644 (file)
@@ -75,7 +75,7 @@ export class GenericServerBlocklistComponent extends RestTable implements OnInit
     })
   }
 
-  protected reloadData () {
+  protected reloadDataInternal () {
     const operation = this.mode === BlocklistComponentType.Account
       ? this.blocklistService.getUserServerBlocklist({
         pagination: this.pagination,
index c69a45c2581fc064c54ae613aebc1aac6453dde0..50dccf862987c28c83960b578162cc6344ec2ef6 100644 (file)
@@ -105,7 +105,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
     const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`)
     if (res === false) return
 
-    this.userAdminService.removeUser(user)
+    this.userAdminService.removeUsers(user)
       .subscribe({
         next: () => {
           this.notifier.success($localize`User ${user.username} deleted.`)
index 20e60486dae5c8d563490c228bf7efd1bcbb7891..95d90e49e70df3e14f606129dca0d521e2ccc974 100644 (file)
@@ -1,5 +1,4 @@
 export * from './user-admin.service'
-export * from './user-signup.service'
 export * from './two-factor.service'
 
 export * from './shared-users.module'
index 5a1675dc94c25565bf0a19f4116503353adaf376..efffc6026a9770cf2c53adbbb2d7f1bd2bbdb83d 100644 (file)
@@ -1,9 +1,7 @@
-
 import { NgModule } from '@angular/core'
 import { SharedMainModule } from '../shared-main/shared-main.module'
 import { TwoFactorService } from './two-factor.service'
 import { UserAdminService } from './user-admin.service'
-import { UserSignupService } from './user-signup.service'
 
 @NgModule({
   imports: [
@@ -15,7 +13,6 @@ import { UserSignupService } from './user-signup.service'
   exports: [],
 
   providers: [
-    UserSignupService,
     UserAdminService,
     TwoFactorService
   ]
index 0b04023a31183183ec315bf0ee8158ce2399ef3b..6224f0bd51bdd644fa75a963e06714b39841baa4 100644 (file)
@@ -64,7 +64,7 @@ export class UserAdminService {
                )
   }
 
-  removeUser (usersArg: UserServerModel | UserServerModel[]) {
+  removeUsers (usersArg: UserServerModel | UserServerModel[]) {
     const users = arrayify(usersArg)
 
     return from(users)
index 6fdf24b2d4f311456113137ae8a8ebd21947ce16..227c1213082e9949f628b48e3f4d83d8e3fba414 100644 (file)
@@ -53,8 +53,8 @@
             <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
           </div>
 
-          <div *ngIf="containedInPlaylists" class="video-contained-in-playlists">
-            <a *ngFor="let playlist of containedInPlaylists" class="chip rectangular bg-secondary text-light" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
+          <div *ngIf="containedInPlaylists" class="fs-6">
+            <a *ngFor="let playlist of containedInPlaylists" class="pt-badge badge-secondary" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
               {{ playlist.playlistDisplayName }}
             </a>
           </div>
index ba2adfc5a851c0c4e2992784776fb4d587b0c654..a397efdca923ac8a0a424c9b53a9b028eb41905f 100644 (file)
@@ -4,10 +4,6 @@
 
 $more-button-width: 40px;
 
-.chip {
-  @include chip;
-}
-
 .video-miniature {
   font-size: 14px;
 }
index 85c63c1738467c67c47954a1b13a805cdf55028d..706227e66339e2a05d499913c2dc697220b156d7 100644 (file)
@@ -314,6 +314,6 @@ export class VideoMiniatureComponent implements OnInit {
           this.cd.markForCheck()
         })
 
-    this.videoPlaylistService.runPlaylistCheck(this.video.id)
+    this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
   }
 }
index d5cdd958e1dbd1fa3d2986f5233b2466731196da..7b832263e5bc0bdc974a7a4e9198fe8129c30429 100644 (file)
@@ -1,6 +1,6 @@
 import * as debug from 'debug'
 import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
-import { debounceTime, switchMap } from 'rxjs/operators'
+import { concatMap, debounceTime, map, switchMap } from 'rxjs/operators'
 import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
 import {
@@ -111,6 +111,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
 
   private lastQueryLength: number
 
+  private videoRequests = new Subject<{ reset: boolean, obs: Observable<ResultList<Video>> }>()
+
   constructor (
     private notifier: Notifier,
     private authService: AuthService,
@@ -124,6 +126,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
   }
 
   ngOnInit () {
+    this.subscribeToVideoRequests()
+
     const hiddenFilters = this.hideScopeFilter
       ? [ 'scope' ]
       : []
@@ -228,30 +232,12 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
   }
 
   loadMoreVideos (reset = false) {
-    if (reset) this.hasDoneFirstQuery = false
-
-    this.getVideosObservableFunction(this.pagination, this.filters)
-      .subscribe({
-        next: ({ data }) => {
-          this.hasDoneFirstQuery = true
-          this.lastQueryLength = data.length
-
-          if (reset) this.videos = []
-          this.videos = this.videos.concat(data)
-
-          if (this.groupByDate) this.buildGroupedDateLabels()
-
-          this.onDataSubject.next(data)
-          this.videosLoaded.emit(this.videos)
-        },
-
-        error: err => {
-          const message = $localize`Cannot load more videos. Try again later.`
+    if (reset) {
+      this.hasDoneFirstQuery = false
+      this.videos = []
+    }
 
-          logger.error(message, err)
-          this.notifier.error(message)
-        }
-      })
+    this.videoRequests.next({ reset, obs: this.getVideosObservableFunction(this.pagination, this.filters) })
   }
 
   reloadVideos () {
@@ -423,4 +409,30 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
       this.onFiltersChanged(true)
     })
   }
+
+  private subscribeToVideoRequests () {
+    this.videoRequests
+      .pipe(concatMap(({ reset, obs }) => obs.pipe(map(({ data }) => ({ data, reset })))))
+      .subscribe({
+        next: ({ data, reset }) => {
+          this.hasDoneFirstQuery = true
+          this.lastQueryLength = data.length
+
+          if (reset) this.videos = []
+          this.videos = this.videos.concat(data)
+
+          if (this.groupByDate) this.buildGroupedDateLabels()
+
+          this.onDataSubject.next(data)
+          this.videosLoaded.emit(this.videos)
+        },
+
+        error: err => {
+          const message = $localize`Cannot load more videos. Try again later.`
+
+          logger.error(message, err)
+          this.notifier.error(message)
+        }
+      })
+  }
 }
index 2fc39fc759f7aaa1a132b78c63ab73077cf03f6c..f802416a4235a8c637d699f6b36e095c4227d010 100644 (file)
@@ -81,7 +81,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
         .subscribe(result => {
           this.playlistsData = result.data
 
-          this.videoPlaylistService.runPlaylistCheck(this.video.id)
+          this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
         })
 
     this.videoPlaylistSearchChanged
@@ -129,7 +129,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
         .subscribe(playlistsResult => {
           this.playlistsData = playlistsResult.data
 
-          this.videoPlaylistService.runPlaylistCheck(this.video.id)
+          this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
         })
   }
 
index 330a51f91a1d878a901c701b45bfc5ff9f7a674f..bc9fb0d7443af034732b7443a405664c6ba5bd86 100644 (file)
@@ -206,7 +206,15 @@ export class VideoPlaylistService {
                      stopTimestamp: body.stopTimestamp
                    })
 
-                   this.runPlaylistCheck(body.videoId)
+                   this.runVideoExistsInPlaylistCheck(body.videoId)
+
+                   if (this.myAccountPlaylistCache) {
+                     const playlist = this.myAccountPlaylistCache.data.find(p => p.id === playlistId)
+                     if (!playlist) return
+
+                     const otherPlaylists = this.myAccountPlaylistCache.data.filter(p => p !== playlist)
+                     this.myAccountPlaylistCache.data = [ playlist, ...otherPlaylists ]
+                   }
                  }),
                  catchError(err => this.restExtractor.handleError(err))
                )
@@ -225,7 +233,7 @@ export class VideoPlaylistService {
                      elem.stopTimestamp = body.stopTimestamp
                    }
 
-                   this.runPlaylistCheck(videoId)
+                   this.runVideoExistsInPlaylistCheck(videoId)
                  }),
                  catchError(err => this.restExtractor.handleError(err))
                )
@@ -242,7 +250,7 @@ export class VideoPlaylistService {
                        .filter(e => e.playlistElementId !== playlistElementId)
                    }
 
-                   this.runPlaylistCheck(videoId)
+                   this.runVideoExistsInPlaylistCheck(videoId)
                  }),
                  catchError(err => this.restExtractor.handleError(err))
                )
@@ -296,7 +304,7 @@ export class VideoPlaylistService {
     return obs
   }
 
-  runPlaylistCheck (videoId: number) {
+  runVideoExistsInPlaylistCheck (videoId: number) {
     debugLogger('Running playlist check.')
 
     if (this.videoExistsCache[videoId]) {
index 56310c4e94a9657f62b049b3524c34c6598e31cb..2781850b92bc34a567e9036a0065b04df4a57c63 100644 (file)
@@ -11,6 +11,7 @@ import './shared/control-bar/p2p-info-button'
 import './shared/control-bar/peertube-link-button'
 import './shared/control-bar/peertube-load-progress-bar'
 import './shared/control-bar/theater-button'
+import './shared/control-bar/peertube-live-display'
 import './shared/settings/resolution-menu-button'
 import './shared/settings/resolution-menu-item'
 import './shared/settings/settings-dialog'
@@ -96,6 +97,10 @@ export class PeertubePlayerManager {
       videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
         const player = this
 
+        if (!isNaN(+options.common.playbackRate)) {
+          player.playbackRate(+options.common.playbackRate)
+        }
+
         let alreadyFallback = false
 
         const handleError = () => {
@@ -118,7 +123,7 @@ export class PeertubePlayerManager {
         self.addContextMenu(videojsOptionsBuilder, player, options.common)
 
         if (isMobile()) player.peertubeMobile()
-        if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin()
+        if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive })
         if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden')
 
         player.bezels()
index db5b8938db3d382de88dd6231a3ee2a11cc43adf..e71e90713894d04f69141610291c5ede76b914a3 100644 (file)
@@ -1,5 +1,6 @@
 export * from './next-previous-video-button'
 export * from './p2p-info-button'
 export * from './peertube-link-button'
+export * from './peertube-live-display'
 export * from './peertube-load-progress-bar'
 export * 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 (file)
index 0000000..649eb0b
--- /dev/null
@@ -0,0 +1,93 @@
+import videojs from 'video.js'
+import { PeerTubeLinkButtonOptions } from '../../types'
+
+const ClickableComponent = videojs.getComponent('ClickableComponent')
+
+class PeerTubeLiveDisplay extends ClickableComponent {
+  private interval: any
+
+  private contentEl_: any
+
+  constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) {
+    super(player, options as any)
+
+    this.interval = this.setInterval(() => this.updateClass(), 1000)
+
+    this.show()
+    this.updateSync(true)
+  }
+
+  dispose () {
+    if (this.interval) {
+      this.clearInterval(this.interval)
+      this.interval = undefined
+    }
+
+    this.contentEl_ = null
+
+    super.dispose()
+  }
+
+  createEl () {
+    const el = super.createEl('div', {
+      className: 'vjs-live-control vjs-control'
+    })
+
+    this.contentEl_ = videojs.dom.createEl('div', {
+      className: 'vjs-live-display'
+    }, {
+      'aria-live': 'off'
+    })
+
+    this.contentEl_.appendChild(videojs.dom.createEl('span', {
+      className: 'vjs-control-text',
+      textContent: `${this.localize('Stream Type')}\u00a0`
+    }))
+
+    this.contentEl_.appendChild(document.createTextNode(this.localize('LIVE')))
+
+    el.appendChild(this.contentEl_)
+    return el
+  }
+
+  handleClick () {
+    const hlsjs = this.getHLSJS()
+    if (!hlsjs) return
+
+    this.player().currentTime(hlsjs.liveSyncPosition)
+    this.player().play()
+    this.updateSync(true)
+  }
+
+  private updateClass () {
+    const hlsjs = this.getHLSJS()
+    if (!hlsjs) return
+
+    // Not loaded yet
+    if (this.player().currentTime() === 0) return
+
+    const isSync = Math.abs(this.player().currentTime() - hlsjs.liveSyncPosition) < 10
+    this.updateSync(isSync)
+  }
+
+  private updateSync (isSync: boolean) {
+    if (isSync) {
+      this.addClass('synced-with-live-edge')
+      this.removeAttribute('title')
+      this.disable()
+    } else {
+      this.removeClass('synced-with-live-edge')
+      this.setAttribute('title', this.localize('Go back to the live'))
+      this.enable()
+    }
+  }
+
+  private getHLSJS () {
+    const p2pMediaLoader = this.player()?.p2pMediaLoader
+    if (!p2pMediaLoader) return undefined
+
+    return p2pMediaLoader().getHLSJS()
+  }
+}
+
+videojs.registerComponent('PeerTubeLiveDisplay', PeerTubeLiveDisplay)
index ec1e1038bfed90f7bb2bb342d9fbc45fa920da5b..f5b4b3919fdc5a96b1d75d701c302c0d61091969 100644 (file)
@@ -4,6 +4,10 @@ type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardE
 
 const Plugin = videojs.getPlugin('plugin')
 
+export type HotkeysOptions = {
+  isLive: boolean
+}
+
 class PeerTubeHotkeysPlugin extends Plugin {
   private static readonly VOLUME_STEP = 0.1
   private static readonly SEEK_STEP = 5
@@ -12,9 +16,13 @@ class PeerTubeHotkeysPlugin extends Plugin {
 
   private readonly handlers: KeyHandler[]
 
-  constructor (player: videojs.Player, options: videojs.PlayerOptions) {
+  private readonly isLive: boolean
+
+  constructor (player: videojs.Player, options: videojs.PlayerOptions & HotkeysOptions) {
     super(player, options)
 
+    this.isLive = options.isLive
+
     this.handlers = this.buildHandlers()
 
     this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event)
@@ -68,28 +76,6 @@ class PeerTubeHotkeysPlugin extends Plugin {
         }
       },
 
-      // Rewind
-      {
-        accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
-        cb: e => {
-          e.preventDefault()
-
-          const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
-          this.player.currentTime(target)
-        }
-      },
-
-      // Forward
-      {
-        accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
-        cb: e => {
-          e.preventDefault()
-
-          const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
-          this.player.currentTime(target)
-        }
-      },
-
       // Fullscreen
       {
         // f key or Ctrl + Enter
@@ -116,6 +102,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
       {
         accept: e => e.key === '>',
         cb: () => {
+          if (this.isLive) return
+
           const target = Math.min(this.player.playbackRate() + 0.1, 5)
 
           this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -126,6 +114,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
       {
         accept: e => e.key === '<',
         cb: () => {
+          if (this.isLive) return
+
           const target = Math.max(this.player.playbackRate() - 0.1, 0.10)
 
           this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -136,6 +126,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
       {
         accept: e => e.key === ',',
         cb: () => {
+          if (this.isLive) return
+
           this.player.pause()
 
           // Calculate movement distance (assuming 30 fps)
@@ -148,6 +140,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
       {
         accept: e => e.key === '.',
         cb: () => {
+          if (this.isLive) return
+
           this.player.pause()
 
           // Calculate movement distance (assuming 30 fps)
@@ -157,11 +151,47 @@ class PeerTubeHotkeysPlugin extends Plugin {
       }
     ]
 
+    if (this.isLive) return handlers
+
+    return handlers.concat(this.buildVODHandlers())
+  }
+
+  private buildVODHandlers () {
+    const handlers: KeyHandler[] = [
+      // Rewind
+      {
+        accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
+        cb: e => {
+          if (this.isLive) return
+
+          e.preventDefault()
+
+          const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
+          this.player.currentTime(target)
+        }
+      },
+
+      // Forward
+      {
+        accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
+        cb: e => {
+          if (this.isLive) return
+
+          e.preventDefault()
+
+          const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
+          this.player.currentTime(target)
+        }
+      }
+    ]
+
     // 0-9 key handlers
     for (let i = 0; i < 10; i++) {
       handlers.push({
         accept: e => this.isNakedOrShift(e, i + ''),
         cb: e => {
+          if (this.isLive) return
+
           e.preventDefault()
 
           this.player.currentTime(this.player.duration() * i * 0.1)
index 27f3667321933a9a1898df1590afb8d900f126f7..26f923e922cf6165eeb89b14dae1860f4e31e214 100644 (file)
@@ -30,10 +30,7 @@ export class ControlBarOptionsBuilder {
     }
 
     Object.assign(children, {
-      currentTimeDisplay: {},
-      timeDivider: {},
-      durationDisplay: {},
-      liveDisplay: {},
+      ...this.getTimeControls(),
 
       flexibleWidthSpacer: {},
 
@@ -74,7 +71,9 @@ export class ControlBarOptionsBuilder {
   private getSettingsButton () {
     const settingEntries: string[] = []
 
-    settingEntries.push('playbackRateMenuButton')
+    if (!this.options.isLive) {
+      settingEntries.push('playbackRateMenuButton')
+    }
 
     if (this.options.captions === true) settingEntries.push('captionsButton')
 
@@ -90,7 +89,23 @@ export class ControlBarOptionsBuilder {
     }
   }
 
+  private getTimeControls () {
+    if (this.options.isLive) {
+      return {
+        peerTubeLiveDisplay: {}
+      }
+    }
+
+    return {
+      currentTimeDisplay: {},
+      timeDivider: {},
+      durationDisplay: {}
+    }
+  }
+
   private getProgressControl () {
+    if (this.options.isLive) return {}
+
     const loadProgressBar = this.mode === 'webtorrent'
       ? 'peerTubeLoadProgressBar'
       : 'loadProgressBar'
index a14beb347ae290c74fd215e25dcf294478e982c6..7f7d90ab9383434d557a2fc026cbee72a0e7a81c 100644 (file)
@@ -281,8 +281,8 @@ class Html5Hlsjs {
     if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1
     else this.errorCounts[data.type] = 1
 
-    if (data.fatal) logger.warn(error.message)
-    else logger.error(error.message, { data })
+    if (data.fatal) logger.error(error.message, { currentTime: this.player.currentTime(), data })
+    else logger.warn(error.message)
 
     if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) {
       error.code = 2
index f23ae48be2c6fd33f1b190f33826513da30f863d..471a5e46c9e98571241fc670f20e768964c9e0f3 100644 (file)
@@ -182,7 +182,7 @@ class StatsCard extends Component {
     let colorSpace = 'unknown'
     let codecs = 'unknown'
 
-    if (metadata?.streams[0]) {
+    if (metadata?.streams?.[0]) {
       const stream = metadata.streams[0]
 
       colorSpace = stream['color_space'] !== 'unknown'
@@ -193,7 +193,7 @@ class StatsCard extends Component {
     }
 
     const resolution = videoFile?.resolution.label + videoFile?.fps
-    const buffer = this.timeRangesToString(this.player().buffered())
+    const buffer = this.timeRangesToString(this.player_.buffered())
     const progress = this.player_.webtorrent().getTorrent()?.progress
 
     return {
index 3057a5adbdad0714aaf4b5af4b8c895e3fd79aec..3fbcec29c79621f115ddceb9c66c03d0a5d52dd1 100644 (file)
@@ -29,6 +29,8 @@ export interface CustomizationOptions {
   resume?: string
 
   peertubeLink: boolean
+
+  playbackRate?: number | string
 }
 
 export interface CommonOptions extends CustomizationOptions {
index c60154f3b6369d642ceb2da495cb5540f9a58ba5..5674f78cbf2aa500c2da4eecc3d63c7a2315523b 100644 (file)
@@ -3,6 +3,7 @@ import videojs from 'video.js'
 import { Engine } from '@peertube/p2p-media-loader-hlsjs'
 import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
 import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
+import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin'
 import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
 import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
 import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
@@ -44,7 +45,7 @@ declare module 'video.js' {
 
     bezels (): void
     peertubeMobile (): void
-    peerTubeHotkeysPlugin (): void
+    peerTubeHotkeysPlugin (options?: HotkeysOptions): void
 
     stats (options?: StatsCardOptions): StatsForNerdsPlugin
 
index d1fdf73aaa0ee3f4d48746b3bd3746f4dfabcb00..618be62cdd3e5a5eb54b502c61d395899029842c 100644 (file)
@@ -27,6 +27,10 @@ class Logger {
   warn (message: LoggerMessage, meta?: LoggerMeta) {
     this.runHooks('warn', message, meta)
 
+    this.clientWarn(message, meta)
+  }
+
+  clientWarn (message: LoggerMessage, meta?: LoggerMeta) {
     if (meta) console.warn(message, meta)
     else console.warn(message)
   }
@@ -34,6 +38,10 @@ class Logger {
   error (message: LoggerMessage, meta?: LoggerMeta) {
     this.runHooks('error', message, meta)
 
+    this.clientError(message, meta)
+  }
+
+  clientError (message: LoggerMessage, meta?: LoggerMeta) {
     if (meta) console.error(message, meta)
     else console.error(message)
   }
index 6c64e2b014167f3559a74d1180e265a8f2737c47..e5b06a94cbe3173ca9d713d2dc755ccead4ecc9b 100644 (file)
@@ -3,7 +3,7 @@ import * as debug from 'debug'
 import { firstValueFrom, ReplaySubject } from 'rxjs'
 import { first, shareReplay } from 'rxjs/operators'
 import { RegisterClientHelpers } from 'src/types/register-client-option.model'
-import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
+import { getExternalAuthHref, getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
 import {
   ClientHookName,
   clientHookObject,
@@ -16,7 +16,6 @@ import {
   RegisterClientRouteOptions,
   RegisterClientSettingsScriptOptions,
   RegisterClientVideoFieldOptions,
-  RegisteredExternalAuthConfig,
   ServerConfigPlugin
 } from '@shared/models'
 import { environment } from '../environments/environment'
@@ -94,9 +93,13 @@ class PluginsManager {
     return isTheme ? '/themes' : '/plugins'
   }
 
-  static getExternalAuthHref (auth: RegisteredExternalAuthConfig) {
-    return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
+  static getDefaultLoginHref (apiUrl: string, serverConfig: HTMLServerConfig) {
+    if (!serverConfig || serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
 
+    const externalAuths = serverConfig.plugin.registeredExternalAuths
+    if (externalAuths.length !== 1) return undefined
+
+    return getExternalAuthHref(apiUrl, externalAuths[0])
   }
 
   loadPluginsList (config: HTMLServerConfig) {
index bc965331a4819eec19a69f8c9089b0bae26879c9..feb3a6de26dc1a20de7d82f869e237a15d14cef8 100644 (file)
@@ -284,3 +284,9 @@ label + .form-group-description {
     border: 2px solid pvar(--mainColorLightest);
   }
 }
+
+// ---------------------------------------------------------------------------
+
+.chip {
+  @include chip;
+}
index 4bc70d4a966a29f3e59cecb0f2d688b68a957f2f..7efd2fb8166d32a8f42f49ec7a37bbf1fcfe15bf 100644 (file)
@@ -9,6 +9,10 @@
   font-weight: $font-semibold;
   line-height: 1.1;
 
+  &.badge-fs-normal {
+    font-size: 100%;
+  }
+
   &.badge-primary {
     color: pvar(--mainBackgroundColor);
     background-color: pvar(--mainColor);
index e5a40af345af0e659cc61d34ec08eda78d4ec36b..514261d01394c81686f1f13a1fa7973bc5bbffa5 100644 (file)
@@ -15,7 +15,3 @@
   font-display: swap;
   src: url('../fonts/source-sans/WOFF2/VAR/SourceSans3VF-Italic.ttf.woff2') format('woff2');
 }
-
-@mixin muted {
-  color: pvar(--greyForegroundColor) !important;
-}
index b5ccb6598a0de7564290e2f9c22dd0106533151d..8816437d933b9bff77b214e80441fbfdc53378fc 100644 (file)
   max-height: $font-size * $number-of-lines;
 }
 
+@mixin muted {
+  color: pvar(--greyForegroundColor) !important;
+}
+
 @mixin fade-text ($fade-after, $background-color) {
   position: relative;
   overflow: hidden;
 }
 
 @mixin chip {
-  --chip-radius: 5rem;
-  --chip-padding: .2rem .4rem;
-  $avatar-height: 1.2rem;
+  --avatar-size: 1.2rem;
 
-  align-items: center;
-  border-radius: var(--chip-radius);
   display: inline-flex;
-  font-size: 90%;
   color: pvar(--mainForegroundColor);
-  height: $avatar-height;
-  line-height: 1rem;
-  margin: .1rem;
+  height: var(--avatar-size);
   max-width: 320px;
   overflow: hidden;
-  padding: var(--chip-padding);
   text-decoration: none;
   text-overflow: ellipsis;
   vertical-align: middle;
   white-space: nowrap;
 
-  &.rectangular {
-    --chip-radius: .2rem;
-    --chip-padding: .2rem .3rem;
-  }
-
   my-actor-avatar {
-    @include margin-left(-.4rem);
     @include margin-right(.2rem);
+
+    border-radius: 5rem;
+    width: var(--avatar-size);
+    height: var(--avatar-size);
   }
 
   &.two-lines {
-    $avatar-height: 2rem;
+    --avatar-size: 2rem;
 
-    height: $avatar-height;
+    font-size: 14px;
+    line-height: 1rem;
 
     my-actor-avatar {
       display: inline-block;
     }
 
-    div {
-      margin: 0 .1rem;
-
+    > div {
       display: flex;
       flex-direction: column;
-      height: $avatar-height;
       justify-content: center;
     }
   }
index 0082378e44add6f6500f04b69f562aa720f977a5..96b3adf66caf58179c287b9cd5b350cac54bb768 100644 (file)
   }
 
   .vjs-live-control {
-    line-height: $control-bar-height;
-    min-width: 4em;
+    padding: 5px 7px;
+    border-radius: 3px;
+    height: fit-content;
+    margin: auto 10px;
+    font-weight: bold;
+    max-width: fit-content;
+    opacity: 1 !important;
+    line-height: normal;
+    position: relative;
+    top: -1px;
+
+    &.synced-with-live-edge {
+      background: #d7281c;
+    }
+
+    &:not(.synced-with-live-edge) {
+      cursor: pointer;
+      background: #80807f;
+    }
   }
 
   .vjs-peertube {
index 88f6efb6a0602407d746a2da31ed5d7dc002711a..ee66a9db3336c04533f6dfd46591e08c1259ab9c 100644 (file)
@@ -294,6 +294,7 @@ body .p-datepicker .p-datepicker-header .p-datepicker-title select:focus {
 body .p-datepicker table {
   font-size: 14px;
   margin: 0.857em 0 0 0;
+  table-layout: fixed;
 }
 body .p-datepicker table th {
   padding: 0.5em;
index b0bdb2dd92b3c5a6f7fc118807079659ef8b2381..f09c86d148bbf8c9c83ad55ca49e5c91d7dc50bd 100644 (file)
@@ -38,6 +38,7 @@ export class PlayerManagerOptions {
   private enableApi = false
   private startTime: number | string = 0
   private stopTime: number | string
+  private playbackRate: number | string
 
   private title: boolean
   private warningTitle: boolean
@@ -130,6 +131,7 @@ export class PlayerManagerOptions {
       this.subtitle = getParamString(params, 'subtitle')
       this.startTime = getParamString(params, 'start')
       this.stopTime = getParamString(params, 'stop')
+      this.playbackRate = getParamString(params, 'playbackRate')
 
       this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
       this.foregroundColor = getParamString(params, 'foregroundColor')
@@ -210,6 +212,8 @@ export class PlayerManagerOptions {
           ? playlistTracker.getCurrentElement().stopTimestamp
           : this.stopTime,
 
+        playbackRate: this.playbackRate,
+
         videoCaptions,
         inactivityTimeout: 2500,
         videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
index b680bfdfb083e4e6da82631bdcc56749bd9db39e..1799df7b19ebff5e1ba9306490b4d857bc9f3df8 100644 (file)
   dependencies:
     tslib "^2.3.0"
 
+"@arr/every@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@arr/every/-/every-1.0.1.tgz#22fe1f8e6355beca6c7c7bde965eb15cf994387b"
+  integrity sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==
+
 "@assemblyscript/loader@^0.10.1":
   version "0.10.1"
   resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.10.1.tgz#70e45678f06c72fa2e350e8553ec4a4d72b92e06"
     read-package-json-fast "^2.0.3"
     which "^2.0.2"
 
-"@peertube/p2p-media-loader-core@^1.0.13", "@peertube/p2p-media-loader-core@^1.0.8":
-  version "1.0.13"
-  resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.13.tgz#36744a291b69c001b2562c1a93017979f8534ff8"
-  integrity sha512-ArSAaeuxwwBAG0Xd3Gj0TzKObLfJFYzHz9+fREvmUf+GZQEG6qGwWmrdVWL6xjPiEuo6LdFeCOnHSQzAbj/ptg==
+"@peertube/maildev@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@peertube/maildev/-/maildev-1.2.0.tgz#f25ee9fa6a45c0a6bc99c5392f63139eaa8eb088"
+  integrity sha512-VGog0A2gk0P8UnP0ZjCoYQumELiqqQY5i+gt18avTC7NJNJLUxMRMI045NAVSDFVbqt2EJJPsbZf3LFjUWRtmw==
+  dependencies:
+    async "^3.1.0"
+    commander "^8.3.0"
+    mailparser-mit "^1.0.0"
+    rimraf "^3.0.2"
+    smtp-server "^3.9.0"
+    wildstring "1.0.9"
+
+"@peertube/p2p-media-loader-core@^1.0.14":
+  version "1.0.14"
+  resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.14.tgz#b4442dd343d6b30a51502e1240275eb98ef2c788"
+  integrity sha512-tjQv1CNziNY+zYzcL1h4q6AA2WuBUZnBIeVyjWR/EsO1EEC1VMdvPsL02cqYLz9yvIxgycjeTsWCm6XDqNgXRw==
   dependencies:
     bittorrent-tracker "^9.19.0"
     debug "^4.3.4"
     sha.js "^2.4.11"
     simple-peer "^9.11.1"
 
-"@peertube/p2p-media-loader-hlsjs@^1.0.13":
-  version "1.0.13"
-  resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.13.tgz#5305e2008041d01850802544d1c49298f79dd67a"
-  integrity sha512-2BO2oaRsSHEhLkgi2iw1r4n1Yqq1EnyoOgOZccPDqjmHUsZSV/wNrno8WYr6LsleudrHA26Imu57hVD1jDx7lg==
+"@peertube/p2p-media-loader-hlsjs@^1.0.14":
+  version "1.0.14"
+  resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.14.tgz#829629a57608b0e30f4b50bc98578e6bee9f8b9b"
+  integrity sha512-ySUVgUvAFXCE5E94xxjfywQ8xzk3jy9UGVkgi5Oqq+QeY7uG+o7CZ+LsQ/RjXgWBD70tEnyyfADHtL+9FCnwyQ==
   dependencies:
-    "@peertube/p2p-media-loader-core" "^1.0.8"
+    "@peertube/p2p-media-loader-core" "^1.0.14"
     debug "^4.3.4"
     events "^3.3.0"
     m3u8-parser "^4.7.1"
     tokenizr "^1.6.4"
     xmldom "^0.6.0"
 
+"@polka/parse@^1.0.0-next.0":
+  version "1.0.0-next.0"
+  resolved "https://registry.yarnpkg.com/@polka/parse/-/parse-1.0.0-next.0.tgz#3551d792acdf4ad0b053072e57498cbe32e45a94"
+  integrity sha512-zcPNrc3PNrRLSCQ7ca8XR7h18VxdPIXhn+yvrYMdUFCHM7mhXGSPw5xBdbcf/dQ1cI4uE8pDfmm5uU+HX+WfFg==
+
+"@polka/url@^0.5.0":
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/@polka/url/-/url-0.5.0.tgz#b21510597fd601e5d7c95008b76bf0d254ebfd31"
+  integrity sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw==
+
 "@polka/url@^1.0.0-next.20":
   version "1.0.0-next.21"
   resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
   dependencies:
     "@types/node" "*"
 
+"@types/gitconfiglocal@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@types/gitconfiglocal/-/gitconfiglocal-2.0.1.tgz#c134f9fb03d71917afa35c14f3b82085520509a6"
+  integrity sha512-AYC38la5dRwIfbrZhPNIvlGHlIbH+kdl2j8A37twoCQyhKPPoRPfVmoBZKajpLIfV7SMboU6MZ6w/RmZLH68IQ==
+
 "@types/html-minifier-terser@^6.0.0":
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
     "@types/lodash" "*"
 
 "@types/lodash@*":
-  version "4.14.189"
-  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.189.tgz#975ff8c38da5ae58b751127b19ad5e44b5b7f6d2"
-  integrity sha512-kb9/98N6X8gyME9Cf7YaqIMvYGnBSWqEci6tiettE6iJWH1XdJz/PO8LB0GtLCG7x8dU3KWhZT+lA1a35127tA==
+  version "4.14.191"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa"
+  integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==
 
 "@types/magnet-uri@*":
   version "5.1.3"
   integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
 
 "@types/mocha@^10.0.0":
-  version "10.0.0"
-  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.0.tgz#3d9018c575f0e3f7386c1de80ee66cc21fbb7a52"
-  integrity sha512-rADY+HtTOA52l9VZWtgQfn4p+UDVM2eDVkMZT1I6syp0YKxW2F9v+0pbRZLsvskhQv/vMb6ZfCay81GHbz5SHg==
+  version "10.0.1"
+  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.1.tgz#2f4f65bb08bc368ac39c96da7b2f09140b26851b"
+  integrity sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==
 
 "@types/mousetrap@^1.6.9":
   version "1.6.11"
   integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
 
 "@types/node@*", "@types/node@^18.0.0":
-  version "18.11.9"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
-  integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
+  version "18.11.18"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f"
+  integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==
 
 "@types/node@^17.0.42":
   version "17.0.45"
   integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==
 
 "@types/yargs@^17.0.8":
-  version "17.0.13"
-  resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.13.tgz#34cced675ca1b1d51fcf4d34c3c6f0fa142a5c76"
-  integrity sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg==
+  version "17.0.19"
+  resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.19.tgz#8dbecdc9ab48bee0cb74f6e3327de3fa0d0c98ae"
+  integrity sha512-cAx3qamwaYX9R0fzOIZAlFpo4A+1uBVCxqpKz9D26uTF4srRXaGTTsikQmaotCtNdbhzyUH7ft6p9ktz9s6UNQ==
   dependencies:
     "@types/yargs-parser" "*"
 
     is-function "^1.0.1"
 
 "@wdio/browserstack-service@^7.25.2":
-  version "7.26.0"
-  resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-7.26.0.tgz#d303c5998e565734bd7f5c23fc9b291a588b7c21"
-  integrity sha512-hRKmg4u/DRNZm1EJGaYESAH6GsCPCtBm15fP9ngm/HFUG084thFfrD8Tt09hO+KSNoK4tXl4k1ZHZ4akrOq9KA==
+  version "7.29.1"
+  resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-7.29.1.tgz#46282aa07b7c11a51ebac0bff1f12f1badd6e264"
+  integrity sha512-1+MoqlIXIjbh1oEOZcvtemij+Yz/CB6orZjeT3WCoA9oY8Ul8EeIHhfF7GxmE6u0OVofjmC+wfO5NlHYCKgL1w==
   dependencies:
-    "@types/node" "^18.0.0"
+    "@types/gitconfiglocal" "^2.0.1"
     "@wdio/logger" "7.26.0"
+    "@wdio/reporter" "7.25.4"
     "@wdio/types" "7.26.0"
     browserstack-local "^1.4.5"
     form-data "^4.0.0"
+    git-repo-info "^2.1.1"
+    gitconfiglocal "^2.1.0"
     got "^11.0.2"
-    webdriverio "7.26.0"
+    uuid "^8.3.2"
+    webdriverio "7.29.1"
 
 "@wdio/cli@^7.25.2":
-  version "7.26.0"
-  resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-7.26.0.tgz#20c690a5ede4a35cb2f84da9041c250a6013bc54"
-  integrity sha512-xG+ZIzPqzz/Tvhfrogd8oNvTXzzdE+cbkmTHjMGo1hnmnoAQPeAEcV/QqaX5CHFE9DjaguEeadqjcZikB5U2GQ==
+  version "7.29.1"
+  resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-7.29.1.tgz#1b47f5a45f21754d42be814dbae94ff723a6a1a2"
+  integrity sha512-dldHNYlnuFUG10TlENbeL41tujqgYD7S/9nzV1J/szBryCO6AIVz/QWn/AUv3zrsO2sn8TNF8BMEXRvLgCxyeg==
   dependencies:
     "@types/ejs" "^3.0.5"
     "@types/fs-extra" "^9.0.4"
     "@types/recursive-readdir" "^2.2.0"
     "@wdio/config" "7.26.0"
     "@wdio/logger" "7.26.0"
-    "@wdio/protocols" "7.22.0"
+    "@wdio/protocols" "7.27.0"
     "@wdio/types" "7.26.0"
     "@wdio/utils" "7.26.0"
     async-exit-hook "^2.0.1"
     lodash.union "^4.6.0"
     mkdirp "^1.0.4"
     recursive-readdir "^2.2.2"
-    webdriverio "7.26.0"
+    webdriverio "7.29.1"
     yargs "^17.0.0"
     yarn-install "^1.0.0"
 
     glob "^8.0.3"
 
 "@wdio/local-runner@^7.25.2":
-  version "7.26.0"
-  resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-7.26.0.tgz#a056c6e9d73c7f48e54fe3f07ce573a90dae26ab"
-  integrity sha512-GdCP7Y8s8qvoctC0WaSGBSmTSbVw74WEJm6Y3n3DpoCI8ABFNkQlhFlqJH+taQDs3sRVEM65bHGcU4C4FOVWXQ==
+  version "7.29.1"
+  resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-7.29.1.tgz#f93a2953847b4271b59ba1b9635920e8046f0e55"
+  integrity sha512-4w9Dsp9/4+MEU8yG7M8ynsCqpSP6UbKqZ2M/gWpvkvy57rb3eS9evFdIFfRzuQmbsztG9qeAlGILwlZ4/oaopg==
   dependencies:
     "@types/stream-buffers" "^3.0.3"
     "@wdio/logger" "7.26.0"
     "@wdio/repl" "7.26.0"
-    "@wdio/runner" "7.26.0"
+    "@wdio/runner" "7.29.1"
     "@wdio/types" "7.26.0"
     async-exit-hook "^2.0.1"
     split2 "^4.0.0"
     expect-webdriverio "^3.0.0"
     mocha "^10.0.0"
 
-"@wdio/protocols@7.22.0":
-  version "7.22.0"
-  resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-7.22.0.tgz#d89faef687cb08981d734bbc5e5dffc6fb5a064c"
-  integrity sha512-8EXRR+Ymdwousm/VGtW3H1hwxZ/1g1H99A1lF0U4GuJ5cFWHCd0IVE5H31Z52i8ZruouW8jueMkGZPSo2IIUSQ==
+"@wdio/protocols@7.27.0":
+  version "7.27.0"
+  resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-7.27.0.tgz#8e2663ec877dce7a5f76b021209c18dd0132e853"
+  integrity sha512-hT/U22R5i3HhwPjkaKAG0yd59eaOaZB0eibRj2+esCImkb5Y6rg8FirrlYRxIGFVBl0+xZV0jKHzR5+o097nvg==
 
 "@wdio/repl@7.26.0":
   version "7.26.0"
   dependencies:
     "@wdio/utils" "7.26.0"
 
-"@wdio/reporter@7.26.0":
-  version "7.26.0"
-  resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.26.0.tgz#26c0e7114a4c1e7b29a79e4d178e5312e04d7934"
-  integrity sha512-kEb7i1A4V4E1wJgdyvLsDbap4cEp1fPZslErGtbAbK+9HI8Lt/SlTZCiOpZbvhgzvawEqOV6UqxZT1RsL8wZWw==
+"@wdio/reporter@7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.25.4.tgz#b6a69652dd0c4ec131255000af128eac403a18b9"
+  integrity sha512-M37qzEmF5qNffyZmRQGjDlrXqWW21EFvgW8wsv1b/NtfpZc0c0MoRpeh6BnvX1KcE4nCXfjXgSJPOqV4ZCzUEQ==
+  dependencies:
+    "@types/diff" "^5.0.0"
+    "@types/node" "^18.0.0"
+    "@types/object-inspect" "^1.8.0"
+    "@types/supports-color" "^8.1.0"
+    "@types/tmp" "^0.2.0"
+    "@wdio/types" "7.25.4"
+    diff "^5.0.0"
+    fs-extra "^10.0.0"
+    object-inspect "^1.10.3"
+    supports-color "8.1.1"
+
+"@wdio/reporter@7.29.1":
+  version "7.29.1"
+  resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.29.1.tgz#7fc2e3b7aa3843172dcd97221c44257384cbbd27"
+  integrity sha512-mpusCpbw7RxnJSDu9qa1qv5IfEMCh7377y1Typ4J2TlMy+78CQzGZ8coEXjBxLcqijTUwcyyoLNI5yRSvbDExw==
   dependencies:
     "@types/diff" "^5.0.0"
     "@types/node" "^18.0.0"
     object-inspect "^1.10.3"
     supports-color "8.1.1"
 
-"@wdio/runner@7.26.0":
-  version "7.26.0"
-  resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-7.26.0.tgz#c0b2848dc885b655e8690d3e0381dfb0ad221af5"
-  integrity sha512-DhQiOs10oPeLlv7/R+997arPg5OY7iEgespGkn6r+kdx2o+awxa6PFegQrjJmRKUmNv3TTuKXHouP34TbR/8sw==
+"@wdio/runner@7.29.1":
+  version "7.29.1"
+  resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-7.29.1.tgz#9fd2fa6dd28b8b130a10d23452eb155e1e887576"
+  integrity sha512-lJEk/HJ5IiuvAJws8zTx9XL5LJuoexvjWIZmOmFJ6Gv8qRpUx6b0n+JM7vhhbTeIqs4QLXOwTQUHlDDRldQlzQ==
   dependencies:
     "@wdio/config" "7.26.0"
     "@wdio/logger" "7.26.0"
     "@wdio/utils" "7.26.0"
     deepmerge "^4.0.0"
     gaze "^1.1.2"
-    webdriver "7.26.0"
-    webdriverio "7.26.0"
+    webdriver "7.27.0"
+    webdriverio "7.29.1"
+
+"@wdio/shared-store-service@^7.25.2":
+  version "7.29.1"
+  resolved "https://registry.yarnpkg.com/@wdio/shared-store-service/-/shared-store-service-7.29.1.tgz#c43a3dbc7d47c8334970bc173e963688977e8a79"
+  integrity sha512-13VOxyz956DSs2wloQ8gtyEx42zjAuOg+N8/4tGk1p2igPzHB2qUiY/P0yi6zamxYGb6PKLIumIeUjitWHtyWA==
+  dependencies:
+    "@polka/parse" "^1.0.0-next.0"
+    "@wdio/logger" "7.26.0"
+    "@wdio/types" "7.26.0"
+    got "^11.0.2"
+    polka "^0.5.2"
+    webdriverio "7.29.1"
 
 "@wdio/spec-reporter@^7.25.1":
-  version "7.26.0"
-  resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-7.26.0.tgz#13eaa5a0fd089684d4c1bcd8ac11dc8646afb5b7"
-  integrity sha512-oisyVWn+MRoq0We0qORoDHNk+iKr7CFG4+IE5GCRecR8cgP7dUjVXZcEbn6blgRpry4jOxsAl24frfaPDOsZVA==
+  version "7.29.1"
+  resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-7.29.1.tgz#08e13c02ea0876672226d5a2c326dda7e1a66c8e"
+  integrity sha512-bwSGM72QrDedqacY7Wq9Gn86VgRwIGPYzZtcaD7aDnvppCuV8Z/31Wpdfen+CzUk2+whXjXKe66ohPyl9TG5+w==
   dependencies:
     "@types/easy-table" "^1.2.0"
-    "@wdio/reporter" "7.26.0"
+    "@wdio/reporter" "7.29.1"
     "@wdio/types" "7.26.0"
     chalk "^4.0.0"
     easy-table "^1.1.1"
     pretty-ms "^7.0.0"
 
+"@wdio/types@7.25.4":
+  version "7.25.4"
+  resolved "https://registry.yarnpkg.com/@wdio/types/-/types-7.25.4.tgz#6f8f028e3108dc880de5068264695f1572e65352"
+  integrity sha512-muvNmq48QZCvocctnbe0URq2FjJjUPIG4iLoeMmyF0AQgdbjaUkMkw3BHYNHVTbSOU9WMsr2z8alhj/I2H6NRQ==
+  dependencies:
+    "@types/node" "^18.0.0"
+    got "^11.8.1"
+
 "@wdio/types@7.26.0":
   version "7.26.0"
   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:
   resolved "https://registry.yarnpkg.com/addr-to-ip-port/-/addr-to-ip-port-1.5.4.tgz#9542b1c6219fdb8c9ce6cc72c14ee880ab7ddd88"
   integrity sha512-ByxmJgv8vjmDcl3IDToxL2yrWFrRtFpZAToY0f46XFXl8zS081t7El5MXIodwm7RC6DhHBRoOSMLFSPKCtHukg==
 
+addressparser@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746"
+  integrity sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==
+
 adjust-sourcemap-loader@^4.0.0:
   version "4.0.0"
   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:
   integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
 
 anymatch@~3.1.2:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
-  integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+  integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
   dependencies:
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
@@ -3188,7 +3265,7 @@ async-exit-hook@^2.0.1:
   resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3"
   integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==
 
-async@^3.2.3:
+async@^3.1.0, async@^3.2.3:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
   integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==
@@ -3327,6 +3404,11 @@ balanced-match@^2.0.0:
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9"
   integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==
 
+base32.js@0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.1.0.tgz#b582dec693c2f11e893cf064ee6ac5b6131a2202"
+  integrity sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==
+
 base64-js@^1.2.0, base64-js@^1.3.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -3933,9 +4015,9 @@ chunk-store-stream@^4.3.0:
     readable-stream "^3.6.0"
 
 ci-info@^3.2.0:
-  version "3.6.1"
-  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.6.1.tgz#7594f1c95cb7fdfddee7af95a13af7dbc67afdcf"
-  integrity sha512-up5ggbaDqOqJ4UqLKZ2naVkyqSJQgJi5lwD6b6mM748ysrghDBX0bx/qJTUHzw7zu6Mq4gycviSF5hJnwceD8w==
+  version "3.7.1"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.7.1.tgz#708a6cdae38915d597afdf3b145f2f8e1ff55f3f"
+  integrity sha512-4jYS4MOAaCIStSRwiuxc4B8MYhIe676yO1sYGzARnjXkWpmzZMMYxY6zu8WYWDhSuth5zhrQ1rhNSibyyvv4/w==
 
 clean-css@5.2.0:
   version "5.2.0"
@@ -4504,16 +4586,18 @@ decompress-response@^6.0.0:
     mimic-response "^3.1.0"
 
 deep-equal@^2.0.5:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.1.0.tgz#5ba60402cf44ab92c2c07f3f3312c3d857a0e1dd"
-  integrity sha512-2pxgvWu3Alv1PoWEyVg7HS8YhGlUFUV7N5oOvfL6d+7xAmLSemMwv/c8Zv/i9KFzxV5Kt5CAvQc70fLwVuf4UA==
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.0.tgz#5caeace9c781028b9ff459f33b779346637c43e6"
+  integrity sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==
   dependencies:
     call-bind "^1.0.2"
     es-get-iterator "^1.1.2"
     get-intrinsic "^1.1.3"
     is-arguments "^1.1.1"
+    is-array-buffer "^3.0.1"
     is-date-object "^1.0.5"
     is-regex "^1.1.4"
+    is-shared-array-buffer "^1.0.2"
     isarray "^2.0.5"
     object-is "^1.1.5"
     object-keys "^1.1.1"
@@ -4522,7 +4606,7 @@ deep-equal@^2.0.5:
     side-channel "^1.0.4"
     which-boxed-primitive "^1.0.2"
     which-collection "^1.0.1"
-    which-typed-array "^1.1.8"
+    which-typed-array "^1.1.9"
 
 deep-is@^0.1.3:
   version "0.1.4"
@@ -4606,21 +4690,21 @@ devtools-protocol@0.0.981744:
   resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
   integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
 
-devtools-protocol@^0.0.1069585:
-  version "0.0.1069585"
-  resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1069585.tgz#c9a9f330462aabf054d581f254b13774297b84f2"
-  integrity sha512-sHmkZB6immWQWU4Wx3ogXwxjQUvQc92MmUDL52+q1z2hQmvpOcvDmbsjwX7QZOPTA32dMV7fgT6zUytcpPzy4A==
+devtools-protocol@^0.0.1085790:
+  version "0.0.1085790"
+  resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1085790.tgz#315e4700eb960cf111cc908b9be2caca2257cb13"
+  integrity sha512-f5kfwdOTxPqX5v8ZfAAl9xBgoEVazBYtIONDWIRqYbb7yjOIcnk6vpzCgBCQvav5AuBRLzyUGG0V74OAx93LoA==
 
-devtools@7.26.0:
-  version "7.26.0"
-  resolved "https://registry.yarnpkg.com/devtools/-/devtools-7.26.0.tgz#3d568aea2238d190ad0cd71c00483c07c707124a"
-  integrity sha512-+8HNbNpzgo4Sn+WcrvXuwsHW9XPJfLo4bs9lgs6DPJHIIDXYJXQGsd7940wMX0Rp0D2vHXA4ibK0oTI5rogM3Q==
+devtools@7.28.1:
+  version "7.28.1"
+  resolved "https://registry.yarnpkg.com/devtools/-/devtools-7.28.1.tgz#9699e0ca41c9a3adfa351d8afac2928f8e1d381c"
+  integrity sha512-sDoszzrXDMLiBQqsg9A5gDqDBwhH4sjYzJIW15lQinB8qgNs0y4o1zdfNlqiKs4HstCA2uFixQeibbDCyMa7hQ==
   dependencies:
     "@types/node" "^18.0.0"
     "@types/ua-parser-js" "^0.7.33"
     "@wdio/config" "7.26.0"
     "@wdio/logger" "7.26.0"
-    "@wdio/protocols" "7.22.0"
+    "@wdio/protocols" "7.27.0"
     "@wdio/types" "7.26.0"
     "@wdio/utils" "7.26.0"
     chrome-launcher "^0.15.0"
@@ -4923,18 +5007,19 @@ es-abstract@^1.19.0, es-abstract@^1.20.4:
     unbox-primitive "^1.0.2"
 
 es-get-iterator@^1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.2.tgz#9234c54aba713486d7ebde0220864af5e2b283f7"
-  integrity sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6"
+  integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==
   dependencies:
     call-bind "^1.0.2"
-    get-intrinsic "^1.1.0"
-    has-symbols "^1.0.1"
-    is-arguments "^1.1.0"
+    get-intrinsic "^1.1.3"
+    has-symbols "^1.0.3"
+    is-arguments "^1.1.1"
     is-map "^2.0.2"
     is-set "^2.0.2"
-    is-string "^1.0.5"
+    is-string "^1.0.7"
     isarray "^2.0.5"
+    stop-iteration-iterator "^1.0.0"
 
 es-module-lexer@^0.9.0:
   version "0.9.3"
@@ -5104,7 +5189,7 @@ escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0:
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
   integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
 
-escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
+escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5, escape-string-regexp@~1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
@@ -5404,7 +5489,7 @@ express@^4.17.3:
     utils-merge "1.0.1"
     vary "~1.1.2"
 
-extend@~3.0.2:
+extend@~3.0.0, extend@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
   integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
@@ -5856,6 +5941,18 @@ getpass@^0.1.1:
   dependencies:
     assert-plus "^1.0.0"
 
+git-repo-info@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/git-repo-info/-/git-repo-info-2.1.1.tgz#220ffed8cbae74ef8a80e3052f2ccb5179aed058"
+  integrity sha512-8aCohiDo4jwjOwma4FmYFd3i97urZulL8XL24nIPxuE+GZnfsAyy/g2Shqx6OjUiFKUXZM+Yy+KHnOmmA3FVcg==
+
+gitconfiglocal@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/gitconfiglocal/-/gitconfiglocal-2.1.0.tgz#07c28685c55cc5338b27b5acbcfe34aeb92e43d1"
+  integrity sha512-qoerOEliJn3z+Zyn1HW2F6eoYJqKwS6MgC9cztTLUB/xLWX8gD/6T60pKn4+t/d6tP7JlybI7Z3z+I572CR/Vg==
+  dependencies:
+    ini "^1.3.2"
+
 glob-parent@^5.1.2, glob-parent@~5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
@@ -5887,7 +5984,7 @@ glob@7.2.0:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@8.0.3, glob@^8.0.1, glob@^8.0.3:
+glob@8.0.3, glob@^8.0.1:
   version "8.0.3"
   resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e"
   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:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+glob@^8.0.3:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e"
+  integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^5.0.1"
+    once "^1.3.0"
+
 glob@~7.1.1:
   version "7.1.7"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
@@ -6002,7 +6110,7 @@ gopd@^1.0.1:
   dependencies:
     get-intrinsic "^1.1.3"
 
-got@11.8.5, got@^11.0.2, got@^11.8.1:
+got@11.8.5:
   version "11.8.5"
   resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046"
   integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==
@@ -6019,6 +6127,23 @@ got@11.8.5, got@^11.0.2, got@^11.8.1:
     p-cancelable "^2.0.0"
     responselike "^2.0.0"
 
+got@^11.0.2, got@^11.8.1:
+  version "11.8.6"
+  resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a"
+  integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==
+  dependencies:
+    "@sindresorhus/is" "^4.0.0"
+    "@szmarczak/http-timer" "^4.0.5"
+    "@types/cacheable-request" "^6.0.1"
+    "@types/responselike" "^1.0.0"
+    cacheable-lookup "^5.0.3"
+    cacheable-request "^7.0.2"
+    decompress-response "^6.0.0"
+    http2-wrapper "^1.0.0-beta.5.2"
+    lowercase-keys "^2.0.0"
+    p-cancelable "^2.0.0"
+    responselike "^2.0.0"
+
 graceful-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:
   version "4.2.10"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
@@ -6093,7 +6218,7 @@ has-property-descriptors@^1.0.0:
   dependencies:
     get-intrinsic "^1.1.1"
 
-has-symbols@^1.0.1, has-symbols@^1.0.2, has-symbols@^1.0.3:
+has-symbols@^1.0.2, has-symbols@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
   integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
@@ -6347,7 +6472,7 @@ humanize-ms@^1.2.1:
   dependencies:
     ms "^2.0.0"
 
-iconv-lite@0.4.24, iconv-lite@^0.4.24:
+iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -6469,7 +6594,7 @@ ini@3.0.0:
   resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.0.tgz#2f6de95006923aa75feed8894f5686165adc08f1"
   integrity sha512-TxYQaeNW/N8ymDvwAxPyRbhMBtnEwuvaTYpOQkFx1nSeusgezHniEc/l35Vo4iCq/mMiTJbpD7oYxN98hFlfmw==
 
-ini@^1.3.5:
+ini@^1.3.2, ini@^1.3.5:
   version "1.3.8"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
   integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
@@ -6504,6 +6629,15 @@ internal-slot@^1.0.3:
     has "^1.0.3"
     side-channel "^1.0.4"
 
+internal-slot@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.4.tgz#8551e7baf74a7a6ba5f749cfb16aa60722f0d6f3"
+  integrity sha512-tA8URYccNzMo94s5MQZgH8NB/XTa6HsOo0MLfXTKKEnHVVdegzaQoFZ7Jp44bdvLvY2waT5dc+j5ICEswhi7UQ==
+  dependencies:
+    get-intrinsic "^1.1.3"
+    has "^1.0.3"
+    side-channel "^1.0.4"
+
 interpret@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
@@ -6556,7 +6690,12 @@ ipaddr.js@1.9.1:
   resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0"
   integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==
 
-is-arguments@^1.1.0, is-arguments@^1.1.1:
+ipv6-normalize@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz#1b3258290d365fa83239e89907dde4592e7620a8"
+  integrity sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==
+
+is-arguments@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
   integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==
@@ -6564,6 +6703,15 @@ is-arguments@^1.1.0, is-arguments@^1.1.1:
     call-bind "^1.0.2"
     has-tostringtag "^1.0.0"
 
+is-array-buffer@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.1.tgz#deb1db4fcae48308d54ef2442706c0393997052a"
+  integrity sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ==
+  dependencies:
+    call-bind "^1.0.2"
+    get-intrinsic "^1.1.3"
+    is-typed-array "^1.1.10"
+
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -7508,6 +7656,16 @@ magnet-uri@^6.2.0:
     bep53-range "^1.1.0"
     thirty-two "^1.0.2"
 
+mailparser-mit@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/mailparser-mit/-/mailparser-mit-1.0.0.tgz#19df8436c2a02e1d34a03ec518a2eb065e0a94a4"
+  integrity sha512-sckRITNb3VCT1sQ275g47MAN786pQ5lU20bLY5f794dF/ARGzuVATQ64gO13FOw8jayjFT10e5ttsripKGGXcw==
+  dependencies:
+    addressparser "^1.0.1"
+    iconv-lite "~0.4.24"
+    mime "^1.6.0"
+    uue "^3.1.0"
+
 make-dir@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@@ -7576,6 +7734,13 @@ marky@^1.2.2:
   resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
   integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
 
+matchit@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/matchit/-/matchit-1.1.0.tgz#c4ccf17d9c824cc1301edbcffde9b75a61d10a7c"
+  integrity sha512-+nGYoOlfHmxe5BW5tE0EMJppXEwdSf8uBA1GTZC7Q77kbT35+VKLYJMzVNWCHSsga1ps1tPYFtFyvxvKzWVmMA==
+  dependencies:
+    "@arr/every" "^1.0.0"
+
 mathml-tag-names@^2.1.3:
   version "2.1.3"
   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,
   dependencies:
     mime-db "1.52.0"
 
-mime@1.6.0, mime@^1.4.1:
+mime@1.6.0, mime@^1.4.1, mime@^1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
   integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
@@ -7740,7 +7905,7 @@ minimatch@5.0.1:
   dependencies:
     brace-expansion "^2.0.1"
 
-minimatch@5.1.0, minimatch@^5.0.0, minimatch@^5.0.1, minimatch@^5.1.0:
+minimatch@5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7"
   integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==
@@ -7754,6 +7919,13 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
   dependencies:
     brace-expansion "^1.1.7"
 
+minimatch@^5.0.0, minimatch@^5.0.1, minimatch@^5.1.0:
+  version "5.1.6"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
+  integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
+  dependencies:
+    brace-expansion "^2.0.1"
+
 minimatch@~3.0.2:
   version "3.0.8"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1"
@@ -7848,9 +8020,9 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
 
 mocha@^10.0.0:
-  version "10.1.0"
-  resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.1.0.tgz#dbf1114b7c3f9d0ca5de3133906aea3dfc89ef7a"
-  integrity sha512-vUF7IYxEoN7XhQpFLxQAEMtE4W91acW4B6En9l97MwE9stL1A9gusXfoHZCLVHDUJ/7V5+lbCM6yMqzo5vNymg==
+  version "10.2.0"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8"
+  integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==
   dependencies:
     ansi-colors "4.1.1"
     browser-stdout "1.3.1"
@@ -8080,6 +8252,11 @@ node-releases@^2.0.6:
   resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
   integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
 
+nodemailer@6.7.3:
+  version "6.7.3"
+  resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.3.tgz#b73f9a81b9c8fa8acb4ea14b608f5e725ea8e018"
+  integrity sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==
+
 nopt@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d"
@@ -8267,7 +8444,12 @@ oauth-sign@~0.9.0:
   resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
   integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
 
-object-inspect@^1.10.3, object-inspect@^1.12.2, object-inspect@^1.9.0:
+object-inspect@^1.10.3, object-inspect@^1.9.0:
+  version "1.12.3"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
+  integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
+
+object-inspect@^1.12.2:
   version "1.12.2"
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
   integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
@@ -8775,6 +8957,14 @@ pngjs@^5.0.0:
   resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
   integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
 
+polka@^0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/polka/-/polka-0.5.2.tgz#588bee0c5806dbc6c64958de3a1251860e9f2e26"
+  integrity sha512-FVg3vDmCqP80tOrs+OeNlgXYmFppTXdjD5E7I4ET1NjvtNmQrb1/mJibybKkb/d4NA7YWAr1ojxuhpL3FHqdlw==
+  dependencies:
+    "@polka/url" "^0.5.0"
+    trouter "^2.0.1"
+
 postcss-attribute-case-insensitive@^5.0.2:
   version "5.0.2"
   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:
   integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==
 
 query-selector-shadow-dom@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.0.tgz#8fa7459a4620f094457640e74e953a9dbe61a38e"
-  integrity sha512-bK0/0cCI+R8ZmOF1QjT7HupDUYCxbf/9TJgAmSXQxZpftXmTAeil9DRoCnTDkWbvOyZzhcMBwKpptWcdkGFIMg==
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz#1c7b0058eff4881ac44f45d8f84ede32e9a2f349"
+  integrity sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==
 
 querystring@0.2.0:
   version "0.2.0"
@@ -9736,9 +9926,9 @@ responselike@^2.0.0:
     lowercase-keys "^2.0.0"
 
 resq@^1.9.1:
-  version "1.10.2"
-  resolved "https://registry.yarnpkg.com/resq/-/resq-1.10.2.tgz#cedf4f20d53f6e574b1e12afbda446ad9576c193"
-  integrity sha512-HmgVS3j+FLrEDBTDYysPdPVF9/hioDMJ/otOiQDKqk77YfZeeLOj0qi34yObumcud1gBpk+wpBTEg4kMicD++A==
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/resq/-/resq-1.11.0.tgz#edec8c58be9af800fd628118c0ca8815283de196"
+  integrity sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==
   dependencies:
     fast-deep-equal "^2.0.1"
 
@@ -9835,13 +10025,20 @@ rxjs@6.6.7:
   dependencies:
     tslib "^1.9.0"
 
-rxjs@^7.3.0, rxjs@^7.4.0, rxjs@^7.5.5:
+rxjs@^7.3.0, rxjs@^7.4.0:
   version "7.5.7"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39"
   integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==
   dependencies:
     tslib "^2.1.0"
 
+rxjs@^7.5.5:
+  version "7.8.0"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4"
+  integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==
+  dependencies:
+    tslib "^2.1.0"
+
 safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@ -10191,6 +10388,15 @@ smart-buffer@^4.2.0:
   resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
   integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
 
+smtp-server@^3.9.0:
+  version "3.11.0"
+  resolved "https://registry.yarnpkg.com/smtp-server/-/smtp-server-3.11.0.tgz#8820c191124fab37a8f16c8325a7f1fd38092c4f"
+  integrity sha512-j/W6mEKeMNKuiM9oCAAjm87agPEN1O3IU4cFLT4ZOCyyq3UXN7HiIXF+q7izxJcYSar15B/JaSxcijoPCR8Tag==
+  dependencies:
+    base32.js "0.1.0"
+    ipv6-normalize "1.0.1"
+    nodemailer "6.7.3"
+
 socket.io-client@^4.5.4:
   version "4.5.4"
   resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.5.4.tgz#d3cde8a06a6250041ba7390f08d2468ccebc5ac9"
@@ -10420,6 +10626,13 @@ statuses@2.0.1:
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
 
+stop-iteration-iterator@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4"
+  integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==
+  dependencies:
+    internal-slot "^1.0.4"
+
 stream-browserify@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f"
@@ -10980,6 +11193,13 @@ trim-newlines@^3.0.0:
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
   integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==
 
+trouter@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/trouter/-/trouter-2.0.1.tgz#2726a5f8558e090d24c3a393f09eaab1df232df6"
+  integrity sha512-kr8SKKw94OI+xTGOkfsvwZQ8mWoikZDd2n8XZHjJVZUARZT+4/VV6cacRS6CLsH9bNm+HFIPU1Zx4CnNnb4qlQ==
+  dependencies:
+    matchit "^1.0.0"
+
 ts-loader@^9.3.0:
   version "9.4.1"
   resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.4.1.tgz#b6f3d82db0eac5a8295994f8cb5e4940ff6b1060"
@@ -11280,6 +11500,14 @@ utp-native@^2.5.3:
     timeout-refresh "^1.0.0"
     unordered-set "^2.0.1"
 
+uue@^3.1.0:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/uue/-/uue-3.1.2.tgz#e99368414e87200012eb37de4dbaebaa1c742ad2"
+  integrity sha512-axKLXVqwtdI/czrjG0X8hyV1KLgeWx8F4KvSbvVCnS+RUvsQMGRjx0kfuZDXXqj0LYvVJmx3B9kWlKtEdRrJLg==
+  dependencies:
+    escape-string-regexp "~1.0.5"
+    extend "~3.0.0"
+
 uuid@8.3.2, uuid@^8.3.2:
   version "8.3.2"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
@@ -11415,31 +11643,31 @@ wdio-geckodriver-service@^3.0.2:
     split2 "^4.1.0"
     tcp-port-used "^1.0.2"
 
-webdriver@7.26.0:
-  version "7.26.0"
-  resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-7.26.0.tgz#cc20640ee9906c0126044449dfe9562b6277d14e"
-  integrity sha512-T21T31wq29D/rmpFHcAahhdrvfsfXsLs/LBe2su7wL725ptOEoSssuDXjXMkwjf9MSUIXnTcUIz8oJGbKRUMwQ==
+webdriver@7.27.0:
+  version "7.27.0"
+  resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-7.27.0.tgz#41d23a6c38bd79ea868f0b9fb9c9e3d4b6e4f8bd"
+  integrity sha512-870uIBnrGJ86g3DdYjM+PHhqdWf6NxysSme1KIs6irWxK+LqcaWKWhN75PldE+04xJB2mVWt1tKn0NBBFTWeMg==
   dependencies:
     "@types/node" "^18.0.0"
     "@wdio/config" "7.26.0"
     "@wdio/logger" "7.26.0"
-    "@wdio/protocols" "7.22.0"
+    "@wdio/protocols" "7.27.0"
     "@wdio/types" "7.26.0"
     "@wdio/utils" "7.26.0"
     got "^11.0.2"
     ky "0.30.0"
     lodash.merge "^4.6.1"
 
-webdriverio@7.26.0:
-  version "7.26.0"
-  resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-7.26.0.tgz#d6036d950ef96fb6cc29c6c5c9cfc452fcafa59a"
-  integrity sha512-7m9TeP871aYxZYKBI4GDh5aQZLN9Fd/PASu5K/jEIT65J4OBB6g5ZaycGFOmfNHCfjWKjwPXZuKiN1f2mcrcRg==
+webdriverio@7.29.1:
+  version "7.29.1"
+  resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-7.29.1.tgz#f71c9de317326cff36d22f6277669477e5340c6f"
+  integrity sha512-2xhoaZvV0tzOgnj8H/B4Yol8LTcIrWdfTdfe01d+ERtdzKCoqimmPNP4vpr2lVRVKL/TW4rfoBTBNvDUaJHe2g==
   dependencies:
     "@types/aria-query" "^5.0.0"
     "@types/node" "^18.0.0"
     "@wdio/config" "7.26.0"
     "@wdio/logger" "7.26.0"
-    "@wdio/protocols" "7.22.0"
+    "@wdio/protocols" "7.27.0"
     "@wdio/repl" "7.26.0"
     "@wdio/types" "7.26.0"
     "@wdio/utils" "7.26.0"
@@ -11447,8 +11675,8 @@ webdriverio@7.26.0:
     aria-query "^5.0.0"
     css-shorthand-properties "^1.1.1"
     css-value "^0.0.1"
-    devtools "7.26.0"
-    devtools-protocol "^0.0.1069585"
+    devtools "7.28.1"
+    devtools-protocol "^0.0.1085790"
     fs-extra "^10.0.0"
     grapheme-splitter "^1.0.2"
     lodash.clonedeep "^4.5.0"
@@ -11461,7 +11689,7 @@ webdriverio@7.26.0:
     resq "^1.9.1"
     rgb2hex "0.2.5"
     serialize-error "^8.0.0"
-    webdriver "7.26.0"
+    webdriver "7.27.0"
 
 webidl-conversions@^3.0.0:
   version "3.0.1"
@@ -11732,7 +11960,7 @@ which-module@^2.0.0:
   resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
   integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==
 
-which-typed-array@^1.1.8:
+which-typed-array@^1.1.9:
   version "1.1.9"
   resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.9.tgz#307cf898025848cf995e795e8423c7f337efbde6"
   integrity sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==
@@ -11770,6 +11998,11 @@ wildcard@^2.0.0:
   resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"
   integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==
 
+wildstring@1.0.9:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/wildstring/-/wildstring-1.0.9.tgz#82a696d5653c7d4ec9ba716859b6b53aba2761c5"
+  integrity sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==
+
 word-wrap@^1.2.3:
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
index 20094ae8fce9cdc7a975361b3ef2005ac7f59ca3..37059e9e01cafdcd6ff84c86e5a75d4a708c9943 100644 (file)
@@ -37,6 +37,11 @@ rates_limit:
     window: 10 minutes
     max: 10
 
+oauth2:
+  token_lifetime:
+    access_token: '1 day'
+    refresh_token: '2 weeks'
+
 # Proxies to trust to get real client IP
 # If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
 # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
@@ -377,9 +382,15 @@ contact_form:
 
 signup:
   enabled: false
+
   limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
+
   minimum_age: 16 # Used to configure the signup form
+
+  # Users fill a form to register so moderators can accept/reject the registration
+  requires_approval: true
   requires_email_verification: false
+
   filters:
     cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
       whitelist: []
index ef93afc19322782a6c6e5e76f27e0738781969a4..44856fb6a5825c37d0ab65393c5b9ddaf259a103 100644 (file)
@@ -8,6 +8,11 @@ webserver:
 secrets:
   peertube: 'my super dev secret'
 
+rates_limit:
+  signup:
+    window: 5 minutes
+    max: 200
+
 database:
   hostname: 'localhost'
   port: 5432
index e8b354d0109332a125db7af7c279cde1617c0045..906fb7e1f1f10bf6aab5febb4b933cdcfe68b938 100644 (file)
@@ -35,6 +35,11 @@ rates_limit:
     window: 10 minutes
     max: 10
 
+oauth2:
+  token_lifetime:
+    access_token: '1 day'
+    refresh_token: '2 weeks'
+
 # Proxies to trust to get real client IP
 # If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
 # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
@@ -387,9 +392,15 @@ contact_form:
 
 signup:
   enabled: false
+
   limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
+
   minimum_age: 16 # Used to configure the signup form
+
+  # Users fill a form to register so moderators can accept/reject the registration
+  requires_approval: true
   requires_email_verification: false
+
   filters:
     cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
       whitelist: []
index 878d68cb9829768a7a53fbcc3bfc868a78e10790..94d74ffa51bd7e775bc1972afde680743be5dbe7 100644 (file)
@@ -74,6 +74,7 @@ cache:
 
 signup:
   enabled: true
+  requires_approval: false
   requires_email_verification: false
 
 transcoding:
index d7d19afc2a3bb9bd663a4351a689eb78d59e071a..b48f65bbd85093bb11f55a6cc252a22e0432671d 100644 (file)
     "swagger-cli": "^4.0.2",
     "ts-node": "^10.8.1",
     "tsc-watch": "^5.0.3",
-    "typescript": "^4.0.5"
+    "typescript": "~4.8"
   },
   "bundlewatch": {
     "files": [
index bcd7fe2a2d3744d02aa1cc9dc97654b9812c4ff7..eca2fe09d7850a28255e8cf50ffed6b15fc00013 100755 (executable)
@@ -41,6 +41,7 @@ const playerKeys = {
   'Volume': 'Volume',
   'Codecs': 'Codecs',
   'Color': 'Color',
+  'Go back to the live': 'Go back to the live',
   'Connection Speed': 'Connection Speed',
   'Network Activity': 'Network Activity',
   'Total Transfered': 'Total Transfered',
@@ -89,7 +90,6 @@ Object.values(VIDEO_CATEGORIES)
 
 // More keys
 Object.assign(serverKeys, {
-  Misc: 'Misc',
   Unknown: 'Unknown'
 })
 
index dd595e9512303e22a6f6e5fb4fcf1f5c0d0f5df2..f6a153fb77e4c091a7cd62acf5623b245740e00f 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -279,7 +279,7 @@ app.use((err, _req, res: express.Response, _next) => {
   })
 })
 
-const server = createWebsocketTrackerServer(app)
+const { server, trackerServer } = createWebsocketTrackerServer(app)
 
 // ----------- Run -----------
 
@@ -328,7 +328,8 @@ async function startApplication () {
   VideoChannelSyncLatestScheduler.Instance.enable()
   VideoViewsBufferScheduler.Instance.enable()
   GeoIPUpdateScheduler.Instance.enable()
-  OpenTelemetryMetrics.Instance.registerMetrics()
+
+  OpenTelemetryMetrics.Instance.registerMetrics({ trackerServer })
 
   PluginManager.Instance.init(server)
   // Before PeerTubeSocket init
index 8e064fb5bc8189759beb7bcc7546e18f8ea6dbc4..def3207300dec8b843e56a0d749235f1ba5048a1 100644 (file)
@@ -309,7 +309,7 @@ async function videoCommentsController (req: express.Request, res: express.Respo
   if (redirectIfNotOwned(video.url, res)) return
 
   const handler = async (start: number, count: number) => {
-    const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count)
+    const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
 
     return {
       total: result.total,
index f0fb43071b82cd501149c70c065cfd15467b25fa..86434f382fbf24799fa2a331425449ca131727ab 100644 (file)
@@ -193,6 +193,7 @@ function customConfig (): CustomConfig {
     signup: {
       enabled: CONFIG.SIGNUP.ENABLED,
       limit: CONFIG.SIGNUP.LIMIT,
+      requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
       requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION,
       minimumAge: CONFIG.SIGNUP.MINIMUM_AGE
     },
diff --git a/server/controllers/api/users/email-verification.ts b/server/controllers/api/users/email-verification.ts
new file mode 100644 (file)
index 0000000..230aaa9
--- /dev/null
@@ -0,0 +1,72 @@
+import express from 'express'
+import { HttpStatusCode } from '@shared/models'
+import { CONFIG } from '../../../initializers/config'
+import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
+import { asyncMiddleware, buildRateLimiter } from '../../../middlewares'
+import {
+  registrationVerifyEmailValidator,
+  usersAskSendVerifyEmailValidator,
+  usersVerifyEmailValidator
+} from '../../../middlewares/validators'
+
+const askSendEmailLimiter = buildRateLimiter({
+  windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
+  max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
+})
+
+const emailVerificationRouter = express.Router()
+
+emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ],
+  askSendEmailLimiter,
+  asyncMiddleware(usersAskSendVerifyEmailValidator),
+  asyncMiddleware(reSendVerifyUserEmail)
+)
+
+emailVerificationRouter.post('/:id/verify-email',
+  asyncMiddleware(usersVerifyEmailValidator),
+  asyncMiddleware(verifyUserEmail)
+)
+
+emailVerificationRouter.post('/registrations/:registrationId/verify-email',
+  asyncMiddleware(registrationVerifyEmailValidator),
+  asyncMiddleware(verifyRegistrationEmail)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  emailVerificationRouter
+}
+
+async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
+  const user = res.locals.user
+  const registration = res.locals.userRegistration
+
+  if (user) await sendVerifyUserEmail(user)
+  else if (registration) await sendVerifyRegistrationEmail(registration)
+
+  return res.status(HttpStatusCode.NO_CONTENT_204).end()
+}
+
+async function verifyUserEmail (req: express.Request, res: express.Response) {
+  const user = res.locals.user
+  user.emailVerified = true
+
+  if (req.body.isPendingEmail === true) {
+    user.email = user.pendingEmail
+    user.pendingEmail = null
+  }
+
+  await user.save()
+
+  return res.status(HttpStatusCode.NO_CONTENT_204).end()
+}
+
+async function verifyRegistrationEmail (req: express.Request, res: express.Response) {
+  const registration = res.locals.userRegistration
+  registration.emailVerified = true
+
+  await registration.save()
+
+  return res.status(HttpStatusCode.NO_CONTENT_204).end()
+}
index a8677a1d325a78afdfaf19d408c52fde4a5de19d..5a5a12e82971935716e8c126a82df7d1aac0f198 100644 (file)
@@ -4,26 +4,21 @@ import { Hooks } from '@server/lib/plugins/hooks'
 import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
 import { MUserAccountDefault } from '@server/types/models'
 import { pick } from '@shared/core-utils'
-import { HttpStatusCode, UserCreate, UserCreateResult, UserRegister, UserRight, UserUpdate } from '@shared/models'
+import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@shared/models'
 import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
 import { logger } from '../../../helpers/logger'
 import { generateRandomString, getFormattedObjects } from '../../../helpers/utils'
-import { CONFIG } from '../../../initializers/config'
 import { WEBSERVER } from '../../../initializers/constants'
 import { sequelizeTypescript } from '../../../initializers/database'
 import { Emailer } from '../../../lib/emailer'
-import { Notifier } from '../../../lib/notifier'
 import { Redis } from '../../../lib/redis'
-import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user'
+import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user'
 import {
   adminUsersSortValidator,
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
   authenticate,
-  buildRateLimiter,
   ensureUserHasRight,
-  ensureUserRegistrationAllowed,
-  ensureUserRegistrationAllowedForIP,
   paginationValidator,
   setDefaultPagination,
   setDefaultSort,
@@ -31,19 +26,17 @@ import {
   usersAddValidator,
   usersGetValidator,
   usersListValidator,
-  usersRegisterValidator,
   usersRemoveValidator,
   usersUpdateValidator
 } from '../../../middlewares'
 import {
   ensureCanModerateUser,
   usersAskResetPasswordValidator,
-  usersAskSendVerifyEmailValidator,
   usersBlockingValidator,
-  usersResetPasswordValidator,
-  usersVerifyEmailValidator
+  usersResetPasswordValidator
 } from '../../../middlewares/validators'
 import { UserModel } from '../../../models/user/user'
+import { emailVerificationRouter } from './email-verification'
 import { meRouter } from './me'
 import { myAbusesRouter } from './my-abuses'
 import { myBlocklistRouter } from './my-blocklist'
@@ -51,22 +44,14 @@ import { myVideosHistoryRouter } from './my-history'
 import { myNotificationsRouter } from './my-notifications'
 import { mySubscriptionsRouter } from './my-subscriptions'
 import { myVideoPlaylistsRouter } from './my-video-playlists'
+import { registrationsRouter } from './registrations'
 import { twoFactorRouter } from './two-factor'
 
 const auditLogger = auditLoggerFactory('users')
 
-const signupRateLimiter = buildRateLimiter({
-  windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
-  max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
-  skipFailedRequests: true
-})
-
-const askSendEmailLimiter = buildRateLimiter({
-  windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
-  max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
-})
-
 const usersRouter = express.Router()
+usersRouter.use('/', emailVerificationRouter)
+usersRouter.use('/', registrationsRouter)
 usersRouter.use('/', twoFactorRouter)
 usersRouter.use('/', tokensRouter)
 usersRouter.use('/', myNotificationsRouter)
@@ -122,14 +107,6 @@ usersRouter.post('/',
   asyncRetryTransactionMiddleware(createUser)
 )
 
-usersRouter.post('/register',
-  signupRateLimiter,
-  asyncMiddleware(ensureUserRegistrationAllowed),
-  ensureUserRegistrationAllowedForIP,
-  asyncMiddleware(usersRegisterValidator),
-  asyncRetryTransactionMiddleware(registerUser)
-)
-
 usersRouter.put('/:id',
   authenticate,
   ensureUserHasRight(UserRight.MANAGE_USERS),
@@ -156,17 +133,6 @@ usersRouter.post('/:id/reset-password',
   asyncMiddleware(resetUserPassword)
 )
 
-usersRouter.post('/ask-send-verify-email',
-  askSendEmailLimiter,
-  asyncMiddleware(usersAskSendVerifyEmailValidator),
-  asyncMiddleware(reSendVerifyUserEmail)
-)
-
-usersRouter.post('/:id/verify-email',
-  asyncMiddleware(usersVerifyEmailValidator),
-  asyncMiddleware(verifyUserEmail)
-)
-
 // ---------------------------------------------------------------------------
 
 export {
@@ -218,35 +184,6 @@ async function createUser (req: express.Request, res: express.Response) {
   })
 }
 
-async function registerUser (req: express.Request, res: express.Response) {
-  const body: UserRegister = req.body
-
-  const userToCreate = buildUser({
-    ...pick(body, [ 'username', 'password', 'email' ]),
-
-    emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
-  })
-
-  const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
-    userToCreate,
-    userDisplayName: body.displayName || undefined,
-    channelNames: body.channel
-  })
-
-  auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
-  logger.info('User %s with its channel and account registered.', body.username)
-
-  if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
-    await sendVerifyUserEmail(user)
-  }
-
-  Notifier.Instance.notifyOnNewUserRegistration(user)
-
-  Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
-
-  return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
-}
-
 async function unblockUser (req: express.Request, res: express.Response) {
   const user = res.locals.user
 
@@ -360,28 +297,6 @@ async function resetUserPassword (req: express.Request, res: express.Response) {
   return res.status(HttpStatusCode.NO_CONTENT_204).end()
 }
 
-async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
-  const user = res.locals.user
-
-  await sendVerifyUserEmail(user)
-
-  return res.status(HttpStatusCode.NO_CONTENT_204).end()
-}
-
-async function verifyUserEmail (req: express.Request, res: express.Response) {
-  const user = res.locals.user
-  user.emailVerified = true
-
-  if (req.body.isPendingEmail === true) {
-    user.email = user.pendingEmail
-    user.pendingEmail = null
-  }
-
-  await user.save()
-
-  return res.status(HttpStatusCode.NO_CONTENT_204).end()
-}
-
 async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) {
   const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
 
diff --git a/server/controllers/api/users/registrations.ts b/server/controllers/api/users/registrations.ts
new file mode 100644 (file)
index 0000000..5e213d6
--- /dev/null
@@ -0,0 +1,249 @@
+import express from 'express'
+import { Emailer } from '@server/lib/emailer'
+import { Hooks } from '@server/lib/plugins/hooks'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
+import { pick } from '@shared/core-utils'
+import {
+  HttpStatusCode,
+  UserRegister,
+  UserRegistrationRequest,
+  UserRegistrationState,
+  UserRegistrationUpdateState,
+  UserRight
+} from '@shared/models'
+import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
+import { logger } from '../../../helpers/logger'
+import { CONFIG } from '../../../initializers/config'
+import { Notifier } from '../../../lib/notifier'
+import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
+import {
+  acceptOrRejectRegistrationValidator,
+  asyncMiddleware,
+  asyncRetryTransactionMiddleware,
+  authenticate,
+  buildRateLimiter,
+  ensureUserHasRight,
+  ensureUserRegistrationAllowedFactory,
+  ensureUserRegistrationAllowedForIP,
+  getRegistrationValidator,
+  listRegistrationsValidator,
+  paginationValidator,
+  setDefaultPagination,
+  setDefaultSort,
+  userRegistrationsSortValidator,
+  usersDirectRegistrationValidator,
+  usersRequestRegistrationValidator
+} from '../../../middlewares'
+
+const auditLogger = auditLoggerFactory('users')
+
+const registrationRateLimiter = buildRateLimiter({
+  windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
+  max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
+  skipFailedRequests: true
+})
+
+const registrationsRouter = express.Router()
+
+registrationsRouter.post('/registrations/request',
+  registrationRateLimiter,
+  asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')),
+  ensureUserRegistrationAllowedForIP,
+  asyncMiddleware(usersRequestRegistrationValidator),
+  asyncRetryTransactionMiddleware(requestRegistration)
+)
+
+registrationsRouter.post('/registrations/:registrationId/accept',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
+  asyncMiddleware(acceptOrRejectRegistrationValidator),
+  asyncRetryTransactionMiddleware(acceptRegistration)
+)
+registrationsRouter.post('/registrations/:registrationId/reject',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
+  asyncMiddleware(acceptOrRejectRegistrationValidator),
+  asyncRetryTransactionMiddleware(rejectRegistration)
+)
+
+registrationsRouter.delete('/registrations/:registrationId',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
+  asyncMiddleware(getRegistrationValidator),
+  asyncRetryTransactionMiddleware(deleteRegistration)
+)
+
+registrationsRouter.get('/registrations',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
+  paginationValidator,
+  userRegistrationsSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  listRegistrationsValidator,
+  asyncMiddleware(listRegistrations)
+)
+
+registrationsRouter.post('/register',
+  registrationRateLimiter,
+  asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')),
+  ensureUserRegistrationAllowedForIP,
+  asyncMiddleware(usersDirectRegistrationValidator),
+  asyncRetryTransactionMiddleware(registerUser)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  registrationsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function requestRegistration (req: express.Request, res: express.Response) {
+  const body: UserRegistrationRequest = req.body
+
+  const registration = new UserRegistrationModel({
+    ...pick(body, [ 'username', 'password', 'email', 'registrationReason' ]),
+
+    accountDisplayName: body.displayName,
+    channelDisplayName: body.channel?.displayName,
+    channelHandle: body.channel?.name,
+
+    state: UserRegistrationState.PENDING,
+
+    emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
+  })
+
+  await registration.save()
+
+  if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
+    await sendVerifyRegistrationEmail(registration)
+  }
+
+  Notifier.Instance.notifyOnNewRegistrationRequest(registration)
+
+  Hooks.runAction('action:api.user.requested-registration', { body, registration, req, res })
+
+  return res.json(registration.toFormattedJSON())
+}
+
+// ---------------------------------------------------------------------------
+
+async function acceptRegistration (req: express.Request, res: express.Response) {
+  const registration = res.locals.userRegistration
+  const body: UserRegistrationUpdateState = req.body
+
+  const userToCreate = buildUser({
+    username: registration.username,
+    password: registration.password,
+    email: registration.email,
+    emailVerified: registration.emailVerified
+  })
+  // We already encrypted password in registration model
+  userToCreate.skipPasswordEncryption = true
+
+  // TODO: handle conflicts if someone else created a channel handle/user handle/user email between registration and approval
+
+  const { user } = await createUserAccountAndChannelAndPlaylist({
+    userToCreate,
+    userDisplayName: registration.accountDisplayName,
+    channelNames: registration.channelHandle && registration.channelDisplayName
+      ? {
+        name: registration.channelHandle,
+        displayName: registration.channelDisplayName
+      }
+      : undefined
+  })
+
+  registration.userId = user.id
+  registration.state = UserRegistrationState.ACCEPTED
+  registration.moderationResponse = body.moderationResponse
+
+  await registration.save()
+
+  logger.info('Registration of %s accepted', registration.username)
+
+  if (body.preventEmailDelivery !== true) {
+    Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
+  }
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function rejectRegistration (req: express.Request, res: express.Response) {
+  const registration = res.locals.userRegistration
+  const body: UserRegistrationUpdateState = req.body
+
+  registration.state = UserRegistrationState.REJECTED
+  registration.moderationResponse = body.moderationResponse
+
+  await registration.save()
+
+  if (body.preventEmailDelivery !== true) {
+    Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
+  }
+
+  logger.info('Registration of %s rejected', registration.username)
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+// ---------------------------------------------------------------------------
+
+async function deleteRegistration (req: express.Request, res: express.Response) {
+  const registration = res.locals.userRegistration
+
+  await registration.destroy()
+
+  logger.info('Registration of %s deleted', registration.username)
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+// ---------------------------------------------------------------------------
+
+async function listRegistrations (req: express.Request, res: express.Response) {
+  const resultList = await UserRegistrationModel.listForApi({
+    start: req.query.start,
+    count: req.query.count,
+    sort: req.query.sort,
+    search: req.query.search
+  })
+
+  return res.json({
+    total: resultList.total,
+    data: resultList.data.map(d => d.toFormattedJSON())
+  })
+}
+
+// ---------------------------------------------------------------------------
+
+async function registerUser (req: express.Request, res: express.Response) {
+  const body: UserRegister = req.body
+
+  const userToCreate = buildUser({
+    ...pick(body, [ 'username', 'password', 'email' ]),
+
+    emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
+  })
+
+  const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
+    userToCreate,
+    userDisplayName: body.displayName || undefined,
+    channelNames: body.channel
+  })
+
+  auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
+  logger.info('User %s with its channel and account registered.', body.username)
+
+  if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
+    await sendVerifyUserEmail(user)
+  }
+
+  Notifier.Instance.notifyOnNewDirectRegistration(user)
+
+  Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
index f8a607170082d9ca03dfeb885ac41d8c1cb05b7b..947f7ca7787de463948e9b48a311c3b2adb57489 100644 (file)
@@ -15,7 +15,7 @@ import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/vid
 import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model'
 import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
 import { resetSequelizeInstance } from '../../helpers/database-utils'
-import { buildNSFWFilter, createReqFiles } from '../../helpers/express-utils'
+import { createReqFiles } from '../../helpers/express-utils'
 import { logger } from '../../helpers/logger'
 import { getFormattedObjects } from '../../helpers/utils'
 import { CONFIG } from '../../initializers/config'
@@ -474,10 +474,7 @@ async function getVideoPlaylistVideos (req: express.Request, res: express.Respon
     'filter:api.video-playlist.videos.list.result'
   )
 
-  const options = {
-    displayNSFW: buildNSFWFilter(res, req.query.nsfw),
-    accountId: user ? user.Account.id : undefined
-  }
+  const options = { accountId: user?.Account?.id }
   return res.json(getFormattedObjects(resultList.data, resultList.total, options))
 }
 
index 44d64776c60a351232c4ea9b3cf547a2f3c28b81..70ca21500a43c2530fd80e2e8a85014f1a012287 100644 (file)
@@ -1,4 +1,6 @@
+import { MCommentFormattable } from '@server/types/models'
 import express from 'express'
+
 import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
 import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
 import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
@@ -109,7 +111,7 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
   const video = res.locals.onlyVideo
   const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
 
-  let resultList: ThreadsResultList<VideoCommentModel>
+  let resultList: ThreadsResultList<MCommentFormattable>
 
   if (video.commentsEnabled === true) {
     const apiOptions = await Hooks.wrapObject({
@@ -144,12 +146,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
   const video = res.locals.onlyVideo
   const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
 
-  let resultList: ResultList<VideoCommentModel>
+  let resultList: ResultList<MCommentFormattable>
 
   if (video.commentsEnabled === true) {
     const apiOptions = await Hooks.wrapObject({
       videoId: video.id,
-      isVideoOwned: video.isOwned(),
       threadId: res.locals.videoCommentThread.id,
       user
     }, 'filter:api.video-thread-comments.list.params')
index 009b6dfb653c56c5ed9a9b93f2ee34006c2d94b1..22387c3e827ae1dc9d5de1f3ca8e923099960dc5 100644 (file)
@@ -22,7 +22,7 @@ export {
 function generateToken (req: express.Request, res: express.Response) {
   const video = res.locals.onlyVideo
 
-  const { token, expires } = VideoTokensManager.Instance.create(video.uuid)
+  const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
 
   return res.json({
     files: {
index 772fe734deda1ebcfb8b7639ab45cf23695010ba..ef810a842fdb79eafed65eb9f4c8ed82381a6d42 100644 (file)
@@ -285,8 +285,8 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
       content: toSafeHtml(video.description),
       author: [
         {
-          name: video.VideoChannel.Account.getDisplayName(),
-          link: video.VideoChannel.Account.Actor.url
+          name: video.VideoChannel.getDisplayName(),
+          link: video.VideoChannel.Actor.url
         }
       ],
       date: video.publishedAt,
index 19a8b2bc937529d999006b7a7a72c8ea7b96ec79..c4f3a8889f75586049a2868298ac5c02f2396802 100644 (file)
@@ -1,17 +1,22 @@
 import { Server as TrackerServer } from 'bittorrent-tracker'
 import express from 'express'
 import { createServer } from 'http'
+import LRUCache from 'lru-cache'
 import proxyAddr from 'proxy-addr'
 import { WebSocketServer } from 'ws'
-import { Redis } from '@server/lib/redis'
 import { logger } from '../helpers/logger'
 import { CONFIG } from '../initializers/config'
-import { TRACKER_RATE_LIMITS } from '../initializers/constants'
+import { LRU_CACHE, TRACKER_RATE_LIMITS } from '../initializers/constants'
 import { VideoFileModel } from '../models/video/video-file'
 import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
 
 const trackerRouter = express.Router()
 
+const blockedIPs = new LRUCache<string, boolean>({
+  max: LRU_CACHE.TRACKER_IPS.MAX_SIZE,
+  ttl: TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME
+})
+
 let peersIps = {}
 let peersIpInfoHash = {}
 runPeersChecker()
@@ -55,8 +60,7 @@ const trackerServer = new TrackerServer({
 
       // Close socket connection and block IP for a few time
       if (params.type === 'ws') {
-        Redis.Instance.setTrackerBlockIP(ip)
-          .catch(err => logger.error('Cannot set tracker block ip.', { err }))
+        blockedIPs.set(ip, true)
 
         // setTimeout to wait filter response
         setTimeout(() => params.socket.close(), 0)
@@ -102,26 +106,22 @@ function createWebsocketTrackerServer (app: express.Application) {
     if (request.url === '/tracker/socket') {
       const ip = proxyAddr(request, CONFIG.TRUST_PROXY)
 
-      Redis.Instance.doesTrackerBlockIPExist(ip)
-        .then(result => {
-          if (result === true) {
-            logger.debug('Blocking IP %s from tracker.', ip)
+      if (blockedIPs.has(ip)) {
+        logger.debug('Blocking IP %s from tracker.', ip)
 
-            socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
-            socket.destroy()
-            return
-          }
+        socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
+        socket.destroy()
+        return
+      }
 
-          // FIXME: typings
-          return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
-        })
-        .catch(err => logger.error('Cannot check if tracker block ip exists.', { err }))
+      // FIXME: typings
+      return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
     }
 
     // Don't destroy socket, we have Socket.IO too
   })
 
-  return server
+  return { server, trackerServer }
 }
 
 // ---------------------------------------------------------------------------
index 3dc5504e32545d611c3b9a1d325621cd6613c87a..b3ab3ac64704edda06955c18d384df3b2e044017 100644 (file)
@@ -103,7 +103,13 @@ function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
 // ---------------------------------------------------------------------------
 
 function toCompleteUUID (value: string) {
-  if (isShortUUID(value)) return shortToUUID(value)
+  if (isShortUUID(value)) {
+    try {
+      return shortToUUID(value)
+    } catch {
+      return null
+    }
+  }
 
   return value
 }
diff --git a/server/helpers/custom-validators/user-registration.ts b/server/helpers/custom-validators/user-registration.ts
new file mode 100644 (file)
index 0000000..9da0bb0
--- /dev/null
@@ -0,0 +1,25 @@
+import validator from 'validator'
+import { CONSTRAINTS_FIELDS, USER_REGISTRATION_STATES } from '../../initializers/constants'
+import { exists } from './misc'
+
+const USER_REGISTRATIONS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USER_REGISTRATIONS
+
+function isRegistrationStateValid (value: string) {
+  return exists(value) && USER_REGISTRATION_STATES[value] !== undefined
+}
+
+function isRegistrationModerationResponseValid (value: string) {
+  return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.MODERATOR_MESSAGE)
+}
+
+function isRegistrationReasonValid (value: string) {
+  return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.REASON_MESSAGE)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isRegistrationStateValid,
+  isRegistrationModerationResponseValid,
+  isRegistrationReasonValid
+}
index 59ba005fe3f4b8d8feb152b9e210b559c72feac5..d5b09ea03628a998a921958bb72fec5c20c572b4 100644 (file)
@@ -8,10 +8,11 @@ function isVideoCaptionLanguageValid (value: any) {
   return exists(value) && VIDEO_LANGUAGES[value] !== undefined
 }
 
-const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
-                                .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
-                                .map(m => `(${m})`)
-                                .join('|')
+// MacOS sends application/octet-stream
+const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ]
+  .map(m => `(${m})`)
+  .join('|')
+
 function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
   return isFileValid({
     files,
index af93aea56062a86ca17b1e441c8b9593de123632..da8962cb6dace3b340f3d478fa6adf6ef4df4e0b 100644 (file)
@@ -22,10 +22,11 @@ function isVideoImportStateValid (value: any) {
   return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined
 }
 
-const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT)
-                                      .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
-                                      .map(m => `(${m})`)
-                                      .join('|')
+// MacOS sends application/octet-stream
+const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ]
+  .map(m => `(${m})`)
+  .join('|')
+
 function isVideoImportTorrentFile (files: UploadFilesForCheck) {
   return isFileValid({
     files,
index e31973b7a61cb5b00b1c7c2c0bb729f1e1a7d8ac..08ab545e42aab664d479bdf87163467f98cbc74d 100644 (file)
@@ -68,7 +68,7 @@ function searchCache (moduleName: string, callback: (current: NodeModule) => voi
 };
 
 function removeCachedPath (pluginPath: string) {
-  const pathCache = (module.constructor as any)._pathCache
+  const pathCache = (module.constructor as any)._pathCache as { [ id: string ]: string[] }
 
   Object.keys(pathCache).forEach(function (cacheKey) {
     if (cacheKey.includes(pluginPath)) {
diff --git a/server/helpers/memoize.ts b/server/helpers/memoize.ts
new file mode 100644 (file)
index 0000000..aa20e7d
--- /dev/null
@@ -0,0 +1,12 @@
+import memoizee from 'memoizee'
+
+export function Memoize (config?: memoizee.Options<any>) {
+  return function (_target, _key, descriptor: PropertyDescriptor) {
+    const oldFunction = descriptor.value
+    const newFunction = memoizee(oldFunction, config)
+
+    descriptor.value = function () {
+      return newFunction.apply(this, arguments)
+    }
+  }
+}
index a2f63095378e2c57b3d876cd697384d8561cc025..765038cea29cd9e6b9ebee0e1663c81f1422aa12 100644 (file)
@@ -6,6 +6,7 @@ import { VideoResolution } from '@shared/models'
 import { logger, loggerTagsFactory } from '../logger'
 import { getProxy, isProxyEnabled } from '../proxy'
 import { isBinaryResponse, peertubeGot } from '../requests'
+import { OptionsOfBufferResponseBody } from 'got/dist/source'
 
 const lTags = loggerTagsFactory('youtube-dl')
 
@@ -28,7 +29,16 @@ export class YoutubeDLCLI {
 
     logger.info('Updating youtubeDL binary from %s.', url, lTags())
 
-    const gotOptions = { context: { bodyKBLimit: 20_000 }, responseType: 'buffer' as 'buffer' }
+    const gotOptions: OptionsOfBufferResponseBody = {
+      context: { bodyKBLimit: 20_000 },
+      responseType: 'buffer' as 'buffer'
+    }
+
+    if (process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN) {
+      gotOptions.headers = {
+        authorization: 'Bearer ' + process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN
+      }
+    }
 
     try {
       let gotResult = await peertubeGot(url, gotOptions)
index c83fef425af8fc4ddd46ade9745000e54fff27e3..0df7414bedb8484414c6a3a5e8f3f5e60e38c751 100644 (file)
@@ -4,7 +4,7 @@ import { getFFmpegVersion } from '@server/helpers/ffmpeg'
 import { uniqify } from '@shared/core-utils'
 import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
 import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
-import { isProdInstance, parseSemVersion } from '../helpers/core-utils'
+import { isProdInstance, parseBytes, parseSemVersion } from '../helpers/core-utils'
 import { isArray } from '../helpers/custom-validators/misc'
 import { logger } from '../helpers/logger'
 import { ApplicationModel, getServerActor } from '../models/application/application'
@@ -116,6 +116,11 @@ function checkEmailConfig () {
       throw new Error('Emailer is disabled but you require signup email verification.')
     }
 
+    if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_APPROVAL) {
+      // eslint-disable-next-line max-len
+      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')
+    }
+
     if (CONFIG.CONTACT_FORM.ENABLED) {
       logger.warn('Emailer is disabled so the contact form will not work.')
     }
@@ -174,7 +179,8 @@ function checkRemoteRedundancyConfig () {
 function checkStorageConfig () {
   // Check storage directory locations
   if (isProdInstance()) {
-    const configStorage = config.get('storage')
+    const configStorage = config.get<{ [ name: string ]: string }>('storage')
+
     for (const key of Object.keys(configStorage)) {
       if (configStorage[key].startsWith('storage/')) {
         logger.warn(
@@ -278,6 +284,11 @@ function checkObjectStorageConfig () {
         'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
       )
     }
+
+    if (CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART > parseBytes('250MB')) {
+      // eslint-disable-next-line max-len
+      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).`)
+    }
   }
 }
 
index 39713a26678692035b653239dddae1627dfbfd74..8b4d491806ede82eba37ea5654f3509447ad1cdd 100644 (file)
@@ -13,6 +13,7 @@ function checkMissedConfig () {
     'webserver.https', 'webserver.hostname', 'webserver.port',
     'secrets.peertube',
     'trust_proxy',
+    'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token',
     'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
     'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
     'email.body.signature', 'email.subject.prefix',
@@ -27,7 +28,7 @@ function checkMissedConfig () {
     'csp.enabled', 'csp.report_only', 'csp.report_uri',
     'security.frameguard.enabled',
     'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled',
-    'signup.enabled', 'signup.limit', 'signup.requires_email_verification', 'signup.minimum_age',
+    'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age',
     'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
     'redundancy.videos.strategies', 'redundancy.videos.check_interval',
     'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled',
index c2f8b19fd60278cf579e54f5680933f33348e5d2..9685e7bfcddcf3f639d2404a8489ed6ce79e8504 100644 (file)
@@ -149,6 +149,12 @@ const CONFIG = {
     HOSTNAME: config.get<string>('webserver.hostname'),
     PORT: config.get<number>('webserver.port')
   },
+  OAUTH2: {
+    TOKEN_LIFETIME: {
+      ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')),
+      REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token'))
+    }
+  },
   RATES_LIMIT: {
     API: {
       WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')),
@@ -299,6 +305,7 @@ const CONFIG = {
   },
   SIGNUP: {
     get ENABLED () { return config.get<boolean>('signup.enabled') },
+    get REQUIRES_APPROVAL () { return config.get<boolean>('signup.requires_approval') },
     get LIMIT () { return config.get<number>('signup.limit') },
     get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') },
     get MINIMUM_AGE () { return config.get<number>('signup.minimum_age') },
index 0e56f0c9f7cc6b04de3d18c8db103c532fafd04a..992c86ed25c91ace624dcf841c9ab1282527e6e3 100644 (file)
@@ -6,6 +6,7 @@ import { randomInt, root } from '@shared/core-utils'
 import {
   AbuseState,
   JobType,
+  UserRegistrationState,
   VideoChannelSyncState,
   VideoImportState,
   VideoPrivacy,
@@ -25,7 +26,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 745
+const LAST_MIGRATION_VERSION = 755
 
 // ---------------------------------------------------------------------------
 
@@ -78,6 +79,8 @@ const SORTABLE_COLUMNS = {
   ACCOUNT_FOLLOWERS: [ 'createdAt' ],
   CHANNEL_FOLLOWERS: [ 'createdAt' ],
 
+  USER_REGISTRATIONS: [ 'createdAt', 'state' ],
+
   VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ],
 
   // Don't forget to update peertube-search-index with the same values
@@ -101,11 +104,6 @@ const SORTABLE_COLUMNS = {
   VIDEO_REDUNDANCIES: [ 'name' ]
 }
 
-const OAUTH_LIFETIME = {
-  ACCESS_TOKEN: 3600 * 24, // 1 day, for upload
-  REFRESH_TOKEN: 1209600 // 2 weeks
-}
-
 const ROUTE_CACHE_LIFETIME = {
   FEEDS: '15 minutes',
   ROBOTS: '2 hours',
@@ -295,6 +293,10 @@ const CONSTRAINTS_FIELDS = {
   ABUSE_MESSAGES: {
     MESSAGE: { min: 2, max: 3000 } // Length
   },
+  USER_REGISTRATIONS: {
+    REASON_MESSAGE: { min: 2, max: 3000 }, // Length
+    MODERATOR_MESSAGE: { min: 2, max: 3000 } // Length
+  },
   VIDEO_BLACKLIST: {
     REASON: { min: 2, max: 300 } // Length
   },
@@ -521,6 +523,12 @@ const ABUSE_STATES: { [ id in AbuseState ]: string } = {
   [AbuseState.ACCEPTED]: 'Accepted'
 }
 
+const USER_REGISTRATION_STATES: { [ id in UserRegistrationState ]: string } = {
+  [UserRegistrationState.PENDING]: 'Pending',
+  [UserRegistrationState.REJECTED]: 'Rejected',
+  [UserRegistrationState.ACCEPTED]: 'Accepted'
+}
+
 const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
   [VideoPlaylistPrivacy.PUBLIC]: 'Public',
   [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
@@ -665,7 +673,7 @@ const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
 
 const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
 
-const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
+const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
 
 const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
   DO_NOT_LIST: 'do_not_list',
@@ -781,6 +789,9 @@ const LRU_CACHE = {
   VIDEO_TOKENS: {
     MAX_SIZE: 100_000,
     TTL: parseDurationToMs('8 hours')
+  },
+  TRACKER_IPS: {
+    MAX_SIZE: 100_000
   }
 }
 
@@ -884,7 +895,7 @@ const TRACKER_RATE_LIMITS = {
   INTERVAL: 60000 * 5, // 5 minutes
   ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval
   ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval
-  BLOCK_IP_LIFETIME: 60000 * 3 // 3 minutes
+  BLOCK_IP_LIFETIME: parseDurationToMs('3 minutes')
 }
 
 const P2P_MEDIA_LOADER_PEER_VERSION = 2
@@ -1030,7 +1041,6 @@ export {
   JOB_ATTEMPTS,
   AP_CLEANER,
   LAST_MIGRATION_VERSION,
-  OAUTH_LIFETIME,
   CUSTOM_HTML_TAG_COMMENTS,
   STATS_TIMESERIE,
   BROADCAST_CONCURRENCY,
@@ -1072,13 +1082,14 @@ export {
   VIDEO_TRANSCODING_FPS,
   FFMPEG_NICE,
   ABUSE_STATES,
+  USER_REGISTRATION_STATES,
   LRU_CACHE,
   REQUEST_TIMEOUTS,
   MAX_LOCAL_VIEWER_WATCH_SECTIONS,
   USER_PASSWORD_RESET_LIFETIME,
   USER_PASSWORD_CREATE_LIFETIME,
   MEMOIZE_TTL,
-  USER_EMAIL_VERIFY_LIFETIME,
+  EMAIL_VERIFY_LIFETIME,
   OVERVIEWS,
   SCHEDULER_INTERVALS_MS,
   REPEAT_JOBS,
index f55f40df011c3395fd3fd3a3b068dcd062c07249..96145f4897e9c85fb55eca29770b6d84ce864d90 100644 (file)
@@ -5,7 +5,9 @@ import { TrackerModel } from '@server/models/server/tracker'
 import { VideoTrackerModel } from '@server/models/server/video-tracker'
 import { UserModel } from '@server/models/user/user'
 import { UserNotificationModel } from '@server/models/user/user-notification'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
 import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
+import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
 import { VideoJobInfoModel } from '@server/models/video/video-job-info'
 import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
 import { VideoSourceModel } from '@server/models/video/video-source'
@@ -50,7 +52,6 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
 import { VideoTagModel } from '../models/video/video-tag'
 import { VideoViewModel } from '../models/view/video-view'
 import { CONFIG } from './config'
-import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
 
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -155,7 +156,8 @@ async function initDatabaseModels (silent: boolean) {
     PluginModel,
     ActorCustomPageModel,
     VideoJobInfoModel,
-    VideoChannelSyncModel
+    VideoChannelSyncModel,
+    UserRegistrationModel
   ])
 
   // Check extensions exist in the database
index f5d8eedf1916a687333ebb118093b6f328466083..f48f348a7bc118a91937b774b7eb1ae4fc92ae46 100644 (file)
@@ -51,8 +51,7 @@ function removeCacheAndTmpDirectories () {
   const tasks: Promise<any>[] = []
 
   // Cache directories
-  for (const key of Object.keys(cacheDirectories)) {
-    const dir = cacheDirectories[key]
+  for (const dir of cacheDirectories) {
     tasks.push(removeDirectoryOrContent(dir))
   }
 
@@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () {
   }
 
   // Cache directories
-  for (const key of Object.keys(cacheDirectories)) {
-    const dir = cacheDirectories[key]
+  for (const dir of cacheDirectories) {
     tasks.push(ensureDir(dir))
   }
 
diff --git a/server/initializers/migrations/0750-user-registration.ts b/server/initializers/migrations/0750-user-registration.ts
new file mode 100644 (file)
index 0000000..15bbfd3
--- /dev/null
@@ -0,0 +1,58 @@
+
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+  db: any
+}): Promise<void> {
+  {
+    const query = `
+      CREATE TABLE IF NOT EXISTS "userRegistration" (
+        "id" serial,
+        "state" integer NOT NULL,
+        "registrationReason" text NOT NULL,
+        "moderationResponse" text,
+        "password" varchar(255),
+        "username" varchar(255) NOT NULL,
+        "email" varchar(400) NOT NULL,
+        "emailVerified" boolean,
+        "accountDisplayName" varchar(255),
+        "channelHandle" varchar(255),
+        "channelDisplayName" varchar(255),
+        "userId" integer REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+        "createdAt" timestamp with time zone NOT NULL,
+        "updatedAt" timestamp with time zone NOT NULL,
+        PRIMARY KEY ("id")
+      );
+    `
+    await utils.sequelize.query(query, { transaction: utils.transaction })
+  }
+
+  {
+    await utils.queryInterface.addColumn('userNotification', 'userRegistrationId', {
+      type: Sequelize.INTEGER,
+      defaultValue: null,
+      allowNull: true,
+      references: {
+        model: 'userRegistration',
+        key: 'id'
+      },
+      onUpdate: 'CASCADE',
+      onDelete: 'SET NULL'
+    }, { transaction: utils.transaction })
+  }
+}
+
+async function down (utils: {
+  queryInterface: Sequelize.QueryInterface
+  transaction: Sequelize.Transaction
+}) {
+  await utils.queryInterface.dropTable('videoChannelSync', { transaction: utils.transaction })
+}
+
+export {
+  up,
+  down
+}
diff --git a/server/initializers/migrations/0755-unique-viewer-url.ts b/server/initializers/migrations/0755-unique-viewer-url.ts
new file mode 100644 (file)
index 0000000..b3dff92
--- /dev/null
@@ -0,0 +1,27 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+  db: any
+}): Promise<void> {
+  const { transaction } = utils
+
+  const query = 'DELETE FROM "localVideoViewer" t1 ' +
+  'USING (SELECT MIN(id) as id, "url" FROM "localVideoViewer" GROUP BY "url" HAVING COUNT(*) > 1) t2 ' +
+  'WHERE t1."url" = t2."url" AND t1.id <> t2.id'
+
+  await utils.sequelize.query(query, { transaction })
+}
+
+async function down (utils: {
+  queryInterface: Sequelize.QueryInterface
+  transaction: Sequelize.Transaction
+}) {
+}
+
+export {
+  up,
+  down
+}
index 0531128016afbe7b8e8b51858daf80aa433328ce..bc5b74257a2a12c559040c5de8690803a2f4e0b4 100644 (file)
@@ -1,26 +1,35 @@
 
-import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users'
+import {
+  isUserAdminFlagsValid,
+  isUserDisplayNameValid,
+  isUserRoleValid,
+  isUserUsernameValid,
+  isUserVideoQuotaDailyValid,
+  isUserVideoQuotaValid
+} from '@server/helpers/custom-validators/users'
 import { logger } from '@server/helpers/logger'
 import { generateRandomString } from '@server/helpers/utils'
 import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
 import { PluginManager } from '@server/lib/plugins/plugin-manager'
 import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
+import { MUser } from '@server/types/models'
 import {
   RegisterServerAuthenticatedResult,
   RegisterServerAuthPassOptions,
   RegisterServerExternalAuthenticatedResult
 } from '@server/types/plugins/register-server-auth.model'
-import { UserRole } from '@shared/models'
+import { UserAdminFlag, UserRole } from '@shared/models'
+import { BypassLogin } from './oauth-model'
+
+export type ExternalUser =
+  Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
+  { displayName: string }
 
 // Token is the key, expiration date is the value
 const authBypassTokens = new Map<string, {
   expires: Date
-  user: {
-    username: string
-    email: string
-    displayName: string
-    role: UserRole
-  }
+  user: ExternalUser
+  userUpdater: RegisterServerAuthenticatedResult['userUpdater']
   authName: string
   npmName: string
 }>()
@@ -56,7 +65,8 @@ async function onExternalUserAuthenticated (options: {
     expires,
     user,
     npmName,
-    authName
+    authName,
+    userUpdater: authResult.userUpdater
   })
 
   // Cleanup expired tokens
@@ -78,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) {
   return tokenModel?.authName
 }
 
-async function getBypassFromPasswordGrant (username: string, password: string) {
+async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
   const plugins = PluginManager.Instance.getIdAndPassAuths()
   const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
 
@@ -133,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
         bypass: true,
         pluginName: pluginAuth.npmName,
         authName: authOptions.authName,
-        user: buildUserResult(loginResult)
+        user: buildUserResult(loginResult),
+        userUpdater: loginResult.userUpdater
       }
     } catch (err) {
       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) {
   return undefined
 }
 
-function getBypassFromExternalAuth (username: string, externalAuthToken: string) {
+function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
   const obj = authBypassTokens.get(externalAuthToken)
   if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
 
@@ -167,33 +178,29 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string)
     bypass: true,
     pluginName: npmName,
     authName,
+    userUpdater: obj.userUpdater,
     user
   }
 }
 
 function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
-  if (!isUserUsernameValid(result.username)) {
-    logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username })
+  const returnError = (field: string) => {
+    logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
     return false
   }
 
-  if (!result.email) {
-    logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email })
-    return false
-  }
+  if (!isUserUsernameValid(result.username)) return returnError('username')
+  if (!result.email) return returnError('email')
 
-  // role is optional
-  if (result.role && !isUserRoleValid(result.role)) {
-    logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role })
-    return false
-  }
+  // Following fields are optional
+  if (result.role && !isUserRoleValid(result.role)) return returnError('role')
+  if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
+  if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
+  if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
+  if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
 
-  // display name is optional
-  if (result.displayName && !isUserDisplayNameValid(result.displayName)) {
-    logger.error(
-      'Auth method %s of plugin %s did not provide a valid display name.',
-      authName, npmName, { displayName: result.displayName }
-    )
+  if (result.userUpdater && typeof result.userUpdater !== 'function') {
+    logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
     return false
   }
 
@@ -205,7 +212,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
     username: pluginResult.username,
     email: pluginResult.email,
     role: pluginResult.role ?? UserRole.USER,
-    displayName: pluginResult.displayName || pluginResult.username
+    displayName: pluginResult.displayName || pluginResult.username,
+
+    adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
+
+    videoQuota: pluginResult.videoQuota,
+    videoQuotaDaily: pluginResult.videoQuotaDaily
   }
 }
 
index 322b69e3a9feea712138b355310f0c5becfee354..43909284f8c10b283d36bc490110b240ff5f7da4 100644 (file)
@@ -1,11 +1,13 @@
 import express from 'express'
 import { AccessDeniedError } from '@node-oauth/oauth2-server'
 import { PluginManager } from '@server/lib/plugins/plugin-manager'
+import { AccountModel } from '@server/models/account/account'
+import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types'
 import { MOAuthClient } from '@server/types/models'
 import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
-import { MUser } from '@server/types/models/user/user'
+import { MUser, MUserDefault } from '@server/types/models/user/user'
 import { pick } from '@shared/core-utils'
-import { UserRole } from '@shared/models/users/user-role'
+import { AttributesOnly } from '@shared/typescript-utils'
 import { logger } from '../../helpers/logger'
 import { CONFIG } from '../../initializers/config'
 import { OAuthClientModel } from '../../models/oauth/oauth-client'
@@ -13,6 +15,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token'
 import { UserModel } from '../../models/user/user'
 import { findAvailableLocalActorName } from '../local-actor'
 import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
+import { ExternalUser } from './external-auth'
 import { TokensCache } from './tokens-cache'
 
 type TokenInfo = {
@@ -26,12 +29,8 @@ export type BypassLogin = {
   bypass: boolean
   pluginName: string
   authName?: string
-  user: {
-    username: string
-    email: string
-    displayName: string
-    role: UserRole
-  }
+  user: ExternalUser
+  userUpdater: RegisterServerAuthenticatedResult['userUpdater']
 }
 
 async function getAccessToken (bearerToken: string) {
@@ -89,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
     logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
 
     let user = await UserModel.loadByEmail(bypassLogin.user.email)
+
     if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
+    else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
 
     // Cannot create a user
     if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
@@ -219,16 +220,11 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function createUserFromExternal (pluginAuth: string, options: {
-  username: string
-  email: string
-  role: UserRole
-  displayName: string
-}) {
-  const username = await findAvailableLocalActorName(options.username)
+async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
+  const username = await findAvailableLocalActorName(userOptions.username)
 
   const userToCreate = buildUser({
-    ...pick(options, [ 'email', 'role' ]),
+    ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
 
     username,
     emailVerified: null,
@@ -238,12 +234,57 @@ async function createUserFromExternal (pluginAuth: string, options: {
 
   const { user } = await createUserAccountAndChannelAndPlaylist({
     userToCreate,
-    userDisplayName: options.displayName
+    userDisplayName: userOptions.displayName
   })
 
   return user
 }
 
+async function updateUserFromExternal (
+  user: MUserDefault,
+  userOptions: ExternalUser,
+  userUpdater: RegisterServerAuthenticatedResult['userUpdater']
+) {
+  if (!userUpdater) return user
+
+  {
+    type UserAttributeKeys = keyof AttributesOnly<UserModel>
+    const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
+      role: 'role',
+      adminFlags: 'adminFlags',
+      videoQuota: 'videoQuota',
+      videoQuotaDaily: 'videoQuotaDaily'
+    }
+
+    for (const modelKey of Object.keys(mappingKeys)) {
+      const pluginOptionKey = mappingKeys[modelKey]
+
+      const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
+      user.set(modelKey, newValue)
+    }
+  }
+
+  {
+    type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
+    const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
+      name: 'displayName'
+    }
+
+    for (const modelKey of Object.keys(mappingKeys)) {
+      const optionKey = mappingKeys[modelKey]
+
+      const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
+      user.Account.set(modelKey, newValue)
+    }
+  }
+
+  logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
+
+  user.Account = await user.Account.save()
+
+  return user.save()
+}
+
 function checkUserValidityOrThrow (user: MUser) {
   if (user.blocked) throw new AccessDeniedError('User is blocked.')
 }
index bc0d4301f082519a02a666d279c0e6bf5ad8c431..887c4f7c9449ca7d9f4adb2ae8afb8e59a3a688a 100644 (file)
@@ -10,20 +10,32 @@ import OAuth2Server, {
 } from '@node-oauth/oauth2-server'
 import { randomBytesPromise } from '@server/helpers/core-utils'
 import { isOTPValid } from '@server/helpers/otp'
+import { CONFIG } from '@server/initializers/config'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
 import { MOAuthClient } from '@server/types/models'
 import { sha1 } from '@shared/extra-utils'
-import { HttpStatusCode } from '@shared/models'
-import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
+import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@shared/models'
+import { OTP } from '../../initializers/constants'
 import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
 
 class MissingTwoFactorError extends Error {
   code = HttpStatusCode.UNAUTHORIZED_401
-  name = 'missing_two_factor'
+  name = ServerErrorCode.MISSING_TWO_FACTOR
 }
 
 class InvalidTwoFactorError extends Error {
   code = HttpStatusCode.BAD_REQUEST_400
-  name = 'invalid_two_factor'
+  name = ServerErrorCode.INVALID_TWO_FACTOR
+}
+
+class RegistrationWaitingForApproval extends Error {
+  code = HttpStatusCode.BAD_REQUEST_400
+  name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL
+}
+
+class RegistrationApprovalRejected extends Error {
+  code = HttpStatusCode.BAD_REQUEST_400
+  name = ServerErrorCode.ACCOUNT_APPROVAL_REJECTED
 }
 
 /**
@@ -32,8 +44,9 @@ class InvalidTwoFactorError extends Error {
  *
  */
 const oAuthServer = new OAuth2Server({
-  accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN,
-  refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN,
+  // Wants seconds
+  accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
+  refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
 
   // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
   model: require('./oauth-model')
@@ -126,7 +139,17 @@ async function handlePasswordGrant (options: {
   }
 
   const user = await getUser(request.body.username, request.body.password, bypassLogin)
-  if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid')
+  if (!user) {
+    const registration = await UserRegistrationModel.loadByEmailOrUsername(request.body.username)
+
+    if (registration?.state === UserRegistrationState.REJECTED) {
+      throw new RegistrationApprovalRejected('Registration approval for this account has been rejected')
+    } else if (registration?.state === UserRegistrationState.PENDING) {
+      throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval')
+    }
+
+    throw new InvalidGrantError('Invalid grant: user credentials are invalid')
+  }
 
   if (user.otpSecret) {
     if (!request.headers[OTP.HEADER_NAME]) {
@@ -182,10 +205,10 @@ function generateRandomToken () {
 
 function getTokenExpiresAt (type: 'access' | 'refresh') {
   const lifetime = type === 'access'
-    ? OAUTH_LIFETIME.ACCESS_TOKEN
-    : OAUTH_LIFETIME.REFRESH_TOKEN
+    ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
+    : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
 
-  return new Date(Date.now() + lifetime * 1000)
+  return new Date(Date.now() + lifetime)
 }
 
 async function buildToken () {
index 410708a352e325bff62db53beca2689548384867..43efc7d02bd8aec815afd4eea7ebbd57a2b8875d 100644 (file)
@@ -36,8 +36,8 @@ export class TokensCache {
     const token = this.userHavingToken.get(userId)
 
     if (token !== undefined) {
-      this.accessTokenCache.del(token)
-      this.userHavingToken.del(userId)
+      this.accessTokenCache.delete(token)
+      this.userHavingToken.delete(userId)
     }
   }
 
@@ -45,8 +45,8 @@ export class TokensCache {
     const tokenModel = this.accessTokenCache.get(token)
 
     if (tokenModel !== undefined) {
-      this.userHavingToken.del(tokenModel.userId)
-      this.accessTokenCache.del(token)
+      this.userHavingToken.delete(tokenModel.userId)
+      this.accessTokenCache.delete(token)
     }
   }
 }
index 39b662eb279cf853072ad3fe8935956c0465a7c7..f5c3e474596abf7ec7b8d30eec98a2560378b89a 100644 (file)
@@ -3,13 +3,13 @@ import { merge } from 'lodash'
 import { createTransport, Transporter } from 'nodemailer'
 import { join } from 'path'
 import { arrayify, root } from '@shared/core-utils'
-import { EmailPayload } from '@shared/models'
+import { EmailPayload, UserRegistrationState } from '@shared/models'
 import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model'
 import { isTestOrDevInstance } from '../helpers/core-utils'
 import { bunyanLogger, logger } from '../helpers/logger'
 import { CONFIG, isEmailEnabled } from '../initializers/config'
 import { WEBSERVER } from '../initializers/constants'
-import { MUser } from '../types/models'
+import { MRegistration, MUser } from '../types/models'
 import { JobQueue } from './job-queue'
 
 const Email = require('email-templates')
@@ -62,7 +62,9 @@ class Emailer {
       subject: 'Reset your account password',
       locals: {
         username,
-        resetPasswordUrl
+        resetPasswordUrl,
+
+        hideNotificationPreferencesLink: true
       }
     }
 
@@ -76,21 +78,33 @@ class Emailer {
       subject: 'Create your account password',
       locals: {
         username,
-        createPasswordUrl
+        createPasswordUrl,
+
+        hideNotificationPreferencesLink: true
       }
     }
 
     return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
   }
 
-  addVerifyEmailJob (username: string, to: string, verifyEmailUrl: string) {
+  addVerifyEmailJob (options: {
+    username: string
+    isRegistrationRequest: boolean
+    to: string
+    verifyEmailUrl: string
+  }) {
+    const { username, isRegistrationRequest, to, verifyEmailUrl } = options
+
     const emailPayload: EmailPayload = {
       template: 'verify-email',
       to: [ to ],
       subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
       locals: {
         username,
-        verifyEmailUrl
+        verifyEmailUrl,
+        isRegistrationRequest,
+
+        hideNotificationPreferencesLink: true
       }
     }
 
@@ -123,7 +137,33 @@ class Emailer {
         body,
 
         // There are not notification preferences for the contact form
-        hideNotificationPreferences: true
+        hideNotificationPreferencesLink: true
+      }
+    }
+
+    return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
+  }
+
+  addUserRegistrationRequestProcessedJob (registration: MRegistration) {
+    let template: string
+    let subject: string
+    if (registration.state === UserRegistrationState.ACCEPTED) {
+      template = 'user-registration-request-accepted'
+      subject = `Your registration request for ${registration.username} has been accepted`
+    } else {
+      template = 'user-registration-request-rejected'
+      subject = `Your registration request for ${registration.username} has been rejected`
+    }
+
+    const to = registration.email
+    const emailPayload: EmailPayload = {
+      to: [ to ],
+      template,
+      subject,
+      locals: {
+        username: registration.username,
+        moderationResponse: registration.moderationResponse,
+        loginLink: WEBSERVER.URL + '/login'
       }
     }
 
index 6da5648e482553a3051b4be9683c28f0c4769d1e..41e94564d9d5cc971b3877806f9b5aaef0b399bc 100644 (file)
@@ -222,19 +222,9 @@ body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule:
           td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;')
         br
         //- Clear Spacer : END
-        //- 1 Column Text : BEGIN
-        if username
-          tr
-            td(style='background-color: #cccccc;')
-              table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
-                tr
-                  td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;')
-                    p(style='margin: 0;')
-                      | You are receiving this email as part of your notification settings on #{instanceName} for your account #{username}.
-        //- 1 Column Text : END
       //- Email Body : END
       //- Email Footer : BEGIN
-      unless hideNotificationPreferences
+      unless hideNotificationPreferencesLink
         table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
           tr
             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 (file)
index 0000000..7a52c3f
--- /dev/null
@@ -0,0 +1,10 @@
+extends ../common/greetings
+
+block title
+  | Congratulation #{username}, your registration request has been accepted!
+
+block content
+  p Your registration request has been accepted.
+  p Moderators sent you the following message:
+  blockquote(style='white-space: pre-wrap') #{moderationResponse}
+  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 (file)
index 0000000..ec0aa8d
--- /dev/null
@@ -0,0 +1,9 @@
+extends ../common/greetings
+
+block title
+  | Registration request of your account #{username} has rejected
+
+block content
+  p Your registration request has been rejected.
+  p Moderators sent you the following message:
+  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 (file)
index 0000000..64898f3
--- /dev/null
@@ -0,0 +1,9 @@
+extends ../common/greetings
+
+block title
+  | A new user wants to register
+
+block content
+  p User #{registration.username} wants to register on your PeerTube instance with the following reason:
+  blockquote(style='white-space: pre-wrap') #{registration.registrationReason}
+  p You can accept or reject the registration request in the #[a(href=`${WEBSERVER.URL}/admin/moderation/registrations/list`) administration].
index be9dde21b883d5244d94df340609245afbc35213..19ef65f757435e7248259b0fb3b46dad617cf84d 100644 (file)
@@ -1,17 +1,19 @@
 extends ../common/greetings
 
 block title
-  | Account verification
+  | Email verification
 
 block content
-  p Welcome to #{instanceName}!
-  p.
-    You just created an account at #[a(href=WEBSERVER.URL) #{instanceName}].
-    Your username there is: #{username}.
-  p.
-    To start using your account you must verify your email first!
-    Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.
-  p.
-    If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
-  p.
-    If you are not the person who initiated this request, please ignore this email.
+  if isRegistrationRequest
+    p You just requested an account on #[a(href=WEBSERVER.URL) #{instanceName}].
+  else
+    p You just created an account on #[a(href=WEBSERVER.URL) #{instanceName}].
+
+  if isRegistrationRequest
+    p To complete your registration request you must verify your email first!
+  else
+    p To start using your account you must verify your email first!
+
+  p Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.
+  p If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
+  p If you are not the person who initiated this request, please ignore this email.
index 866aa1ed0eef876a9e45b719f3b12e753ec9e450..8597eb00018356dc081237b0f48f57cb7f8d6415 100644 (file)
@@ -184,7 +184,7 @@ class JobQueue {
 
     this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST
 
-    for (const handlerName of (Object.keys(handlers) as JobType[])) {
+    for (const handlerName of Object.keys(handlers)) {
       this.buildWorker(handlerName)
       this.buildQueue(handlerName)
       this.buildQueueScheduler(handlerName)
index 66cfc31c4b61da61a1430a4bc84f5a2e717fa203..920c55df057a50b632069556c75b9310189d942f 100644 (file)
@@ -1,4 +1,4 @@
-import { MUser, MUserDefault } from '@server/types/models/user'
+import { MRegistration, MUser, MUserDefault } from '@server/types/models/user'
 import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
 import { UserNotificationSettingValue } from '../../../shared/models/users'
 import { logger } from '../../helpers/logger'
@@ -13,6 +13,7 @@ import {
   AbuseStateChangeForReporter,
   AutoFollowForInstance,
   CommentMention,
+  DirectRegistrationForModerators,
   FollowForInstance,
   FollowForUser,
   ImportFinishedForOwner,
@@ -30,7 +31,7 @@ import {
   OwnedPublicationAfterAutoUnblacklist,
   OwnedPublicationAfterScheduleUpdate,
   OwnedPublicationAfterTranscoding,
-  RegistrationForModerators,
+  RegistrationRequestForModerators,
   StudioEditionFinishedForOwner,
   UnblacklistForOwner
 } from './shared'
@@ -47,7 +48,8 @@ class Notifier {
     newBlacklist: [ NewBlacklistForOwner ],
     unblacklist: [ UnblacklistForOwner ],
     importFinished: [ ImportFinishedForOwner ],
-    userRegistration: [ RegistrationForModerators ],
+    directRegistration: [ DirectRegistrationForModerators ],
+    registrationRequest: [ RegistrationRequestForModerators ],
     userFollow: [ FollowForUser ],
     instanceFollow: [ FollowForInstance ],
     autoInstanceFollow: [ AutoFollowForInstance ],
@@ -138,13 +140,20 @@ class Notifier {
         })
   }
 
-  notifyOnNewUserRegistration (user: MUserDefault): void {
-    const models = this.notificationModels.userRegistration
+  notifyOnNewDirectRegistration (user: MUserDefault): void {
+    const models = this.notificationModels.directRegistration
 
     this.sendNotifications(models, user)
       .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
   }
 
+  notifyOnNewRegistrationRequest (registration: MRegistration): void {
+    const models = this.notificationModels.registrationRequest
+
+    this.sendNotifications(models, registration)
+      .catch(err => logger.error('Cannot notify moderators of new registration request (%s).', registration.username, { err }))
+  }
+
   notifyOfNewUserFollow (actorFollow: MActorFollowFull): void {
     const models = this.notificationModels.userFollow
 
similarity index 90%
rename from server/lib/notifier/shared/instance/registration-for-moderators.ts
rename to server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
index e9246742421555d08c3f50beff52fd715f5e535f..5044f2068439f6c9201aa24b8e810e793c412afd 100644 (file)
@@ -6,7 +6,7 @@ import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi
 import { UserNotificationType, UserRight } from '@shared/models'
 import { AbstractNotification } from '../common/abstract-notification'
 
-export class RegistrationForModerators extends AbstractNotification <MUserDefault> {
+export class DirectRegistrationForModerators extends AbstractNotification <MUserDefault> {
   private moderators: MUserDefault[]
 
   async prepare () {
@@ -40,7 +40,7 @@ export class RegistrationForModerators extends AbstractNotification <MUserDefaul
     return {
       template: 'user-registered',
       to,
-      subject: `a new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
+      subject: `A new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
       locals: {
         user: this.payload
       }
index c3bb22aec106173a0d82a5a464e143c69eb3b44a..8c75a8ee9b7291d68321497684f6b050c5321fea 100644 (file)
@@ -1,3 +1,4 @@
 export * from './new-peertube-version-for-admins'
 export * from './new-plugin-version-for-admins'
-export * from './registration-for-moderators'
+export * from './direct-registration-for-moderators'
+export * 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 (file)
index 0000000..7992024
--- /dev/null
@@ -0,0 +1,48 @@
+import { logger } from '@server/helpers/logger'
+import { UserModel } from '@server/models/user/user'
+import { UserNotificationModel } from '@server/models/user/user-notification'
+import { MRegistration, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
+import { UserNotificationType, UserRight } from '@shared/models'
+import { AbstractNotification } from '../common/abstract-notification'
+
+export class RegistrationRequestForModerators extends AbstractNotification <MRegistration> {
+  private moderators: MUserDefault[]
+
+  async prepare () {
+    this.moderators = await UserModel.listWithRight(UserRight.MANAGE_REGISTRATIONS)
+  }
+
+  log () {
+    logger.info('Notifying %s moderators of new user registration request of %s.', this.moderators.length, this.payload.username)
+  }
+
+  getSetting (user: MUserWithNotificationSetting) {
+    return user.NotificationSetting.newUserRegistration
+  }
+
+  getTargetUsers () {
+    return this.moderators
+  }
+
+  createNotification (user: MUserWithNotificationSetting) {
+    const notification = UserNotificationModel.build<UserNotificationModelForApi>({
+      type: UserNotificationType.NEW_USER_REGISTRATION_REQUEST,
+      userId: user.id,
+      userRegistrationId: this.payload.id
+    })
+    notification.UserRegistration = this.payload
+
+    return notification
+  }
+
+  createEmail (to: string) {
+    return {
+      template: 'user-registration-request',
+      to,
+      subject: `A new user wants to register: ${this.payload.username}`,
+      locals: {
+        registration: this.payload
+      }
+    }
+  }
+}
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 (file)
index 0000000..ef40c0f
--- /dev/null
@@ -0,0 +1,51 @@
+import { Meter } from '@opentelemetry/api'
+
+export class BittorrentTrackerObserversBuilder {
+
+  constructor (private readonly meter: Meter, private readonly trackerServer: any) {
+
+  }
+
+  buildObservers () {
+    const activeInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_active_infohashes_total', {
+      description: 'Total active infohashes in the PeerTube BitTorrent Tracker'
+    })
+    const inactiveInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_inactive_infohashes_total', {
+      description: 'Total inactive infohashes in the PeerTube BitTorrent Tracker'
+    })
+    const peers = this.meter.createObservableGauge('peertube_bittorrent_tracker_peers_total', {
+      description: 'Total peers in the PeerTube BitTorrent Tracker'
+    })
+
+    this.meter.addBatchObservableCallback(observableResult => {
+      const infohashes = Object.keys(this.trackerServer.torrents)
+
+      const counters = {
+        activeInfohashes: 0,
+        inactiveInfohashes: 0,
+        peers: 0,
+        uncompletedPeers: 0
+      }
+
+      for (const infohash of infohashes) {
+        const content = this.trackerServer.torrents[infohash]
+
+        const peers = content.peers
+        if (peers.keys.length !== 0) counters.activeInfohashes++
+        else counters.inactiveInfohashes++
+
+        for (const peerId of peers.keys) {
+          const peer = peers.peek(peerId)
+          if (peer == null) return
+
+          counters.peers++
+        }
+      }
+
+      observableResult.observe(activeInfohashes, counters.activeInfohashes)
+      observableResult.observe(inactiveInfohashes, counters.inactiveInfohashes)
+      observableResult.observe(peers, counters.peers)
+    }, [ activeInfohashes, inactiveInfohashes, peers ])
+  }
+
+}
index 775d954ba1e02349eb1e674aa9dbac6ec6c7b1cd..47b24a54f197065c0615746c81eca1dc1da13779 100644 (file)
@@ -1,3 +1,4 @@
+export * from './bittorrent-tracker-observers-builder'
 export * from './lives-observers-builder'
 export * from './job-queue-observers-builder'
 export * from './nodejs-observers-builder'
index 226d514c0634c468569106d81c0325026717d992..9cc067e4ab047ec6cae54dfb017f794f355c3609 100644 (file)
@@ -7,6 +7,7 @@ import { CONFIG } from '@server/initializers/config'
 import { MVideoImmutable } from '@server/types/models'
 import { PlaybackMetricCreate } from '@shared/models'
 import {
+  BittorrentTrackerObserversBuilder,
   JobQueueObserversBuilder,
   LivesObserversBuilder,
   NodeJSObserversBuilder,
@@ -41,7 +42,7 @@ class OpenTelemetryMetrics {
     })
   }
 
-  registerMetrics () {
+  registerMetrics (options: { trackerServer: any }) {
     if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return
 
     logger.info('Registering Open Telemetry metrics')
@@ -80,6 +81,9 @@ class OpenTelemetryMetrics {
 
     const viewersObserversBuilder = new ViewersObserversBuilder(this.meter)
     viewersObserversBuilder.buildObservers()
+
+    const bittorrentTrackerObserversBuilder = new BittorrentTrackerObserversBuilder(this.meter, options.trackerServer)
+    bittorrentTrackerObserversBuilder.buildObservers()
   }
 
   observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
index 7b1def6e30c9e171f8703f4751047374f8fbbfed..66383af46a0b462e497dd9120d13e6b409b92d30 100644 (file)
@@ -209,6 +209,10 @@ function buildConfigHelpers () {
       return WEBSERVER.URL
     },
 
+    getServerListeningConfig () {
+      return { hostname: CONFIG.LISTEN.HOSTNAME, port: CONFIG.LISTEN.PORT }
+    },
+
     getServerConfig () {
       return ServerConfigManager.Instance.getServerConfig()
     }
@@ -245,7 +249,7 @@ function buildUserHelpers () {
     },
 
     getAuthUser: (res: express.Response) => {
-      const user = res.locals.oauth?.token?.User
+      const user = res.locals.oauth?.token?.User || res.locals.videoFileToken?.user
       if (!user) return undefined
 
       return UserModel.loadByIdFull(user.id)
index c0e9aece747307580e4bc3214b09118ecc2071c3..3706d22282b7b27c7a9cbc1da12da2c05723dd56 100644 (file)
@@ -8,9 +8,8 @@ import {
   AP_CLEANER,
   CONTACT_FORM_LIFETIME,
   RESUMABLE_UPLOAD_SESSION_LIFETIME,
-  TRACKER_RATE_LIMITS,
   TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
-  USER_EMAIL_VERIFY_LIFETIME,
+  EMAIL_VERIFY_LIFETIME,
   USER_PASSWORD_CREATE_LIFETIME,
   USER_PASSWORD_RESET_LIFETIME,
   VIEW_LIFETIME,
@@ -125,16 +124,28 @@ class Redis {
 
   /* ************ Email verification ************ */
 
-  async setVerifyEmailVerificationString (userId: number) {
+  async setUserVerifyEmailVerificationString (userId: number) {
     const generatedString = await generateRandomString(32)
 
-    await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME)
+    await this.setValue(this.generateUserVerifyEmailKey(userId), generatedString, EMAIL_VERIFY_LIFETIME)
 
     return generatedString
   }
 
-  async getVerifyEmailLink (userId: number) {
-    return this.getValue(this.generateVerifyEmailKey(userId))
+  async getUserVerifyEmailLink (userId: number) {
+    return this.getValue(this.generateUserVerifyEmailKey(userId))
+  }
+
+  async setRegistrationVerifyEmailVerificationString (registrationId: number) {
+    const generatedString = await generateRandomString(32)
+
+    await this.setValue(this.generateRegistrationVerifyEmailKey(registrationId), generatedString, EMAIL_VERIFY_LIFETIME)
+
+    return generatedString
+  }
+
+  async getRegistrationVerifyEmailLink (registrationId: number) {
+    return this.getValue(this.generateRegistrationVerifyEmailKey(registrationId))
   }
 
   /* ************ Contact form per IP ************ */
@@ -157,16 +168,6 @@ class Redis {
     return this.exists(this.generateIPViewKey(ip, videoUUID))
   }
 
-  /* ************ Tracker IP block ************ */
-
-  setTrackerBlockIP (ip: string) {
-    return this.setValue(this.generateTrackerBlockIPKey(ip), '1', TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME)
-  }
-
-  async doesTrackerBlockIPExist (ip: string) {
-    return this.exists(this.generateTrackerBlockIPKey(ip))
-  }
-
   /* ************ Video views stats ************ */
 
   addVideoViewStats (videoId: number) {
@@ -357,16 +358,16 @@ class Redis {
     return 'two-factor-request-' + userId + '-' + token
   }
 
-  private generateVerifyEmailKey (userId: number) {
-    return 'verify-email-' + userId
+  private generateUserVerifyEmailKey (userId: number) {
+    return 'verify-email-user-' + userId
   }
 
-  private generateIPViewKey (ip: string, videoUUID: string) {
-    return `views-${videoUUID}-${ip}`
+  private generateRegistrationVerifyEmailKey (registrationId: number) {
+    return 'verify-email-registration-' + registrationId
   }
 
-  private generateTrackerBlockIPKey (ip: string) {
-    return `tracker-block-ip-${ip}`
+  private generateIPViewKey (ip: string, videoUUID: string) {
+    return `views-${videoUUID}-${ip}`
   }
 
   private generateContactFormKey (ip: string) {
index 78a9546ae6e85afdb5e89e9f5db3ff276d118c3c..e87e2854f6d8b5cda66df964cacbe4c3187c1593 100644 (file)
@@ -261,10 +261,17 @@ class ServerConfigManager {
   async getServerConfig (ip?: string): Promise<ServerConfig> {
     const { allowed } = await Hooks.wrapPromiseFun(
       isSignupAllowed,
+
       {
-        ip
+        ip,
+        signupMode: CONFIG.SIGNUP.REQUIRES_APPROVAL
+          ? 'request-registration'
+          : 'direct-registration'
       },
-      'filter:api.user.signup.allowed.result'
+
+      CONFIG.SIGNUP.REQUIRES_APPROVAL
+        ? 'filter:api.user.request-signup.allowed.result'
+        : 'filter:api.user.signup.allowed.result'
     )
 
     const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
@@ -273,6 +280,7 @@ class ServerConfigManager {
       allowed,
       allowedForCurrentIP,
       minimumAge: CONFIG.SIGNUP.MINIMUM_AGE,
+      requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
       requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
     }
 
index f094531eb721926431f9a17f7e80322d21eaebc9..f19232621aa4e792db390d769cd11c2cb508ee4b 100644 (file)
@@ -4,11 +4,24 @@ import { UserModel } from '../models/user/user'
 
 const isCidr = require('is-cidr')
 
-async function isSignupAllowed (): Promise<{ allowed: boolean, errorMessage?: string }> {
+export type SignupMode = 'direct-registration' | 'request-registration'
+
+async function isSignupAllowed (options: {
+  signupMode: SignupMode
+
+  ip: string // For plugins
+  body?: any
+}): Promise<{ allowed: boolean, errorMessage?: string }> {
+  const { signupMode } = options
+
   if (CONFIG.SIGNUP.ENABLED === false) {
     return { allowed: false }
   }
 
+  if (signupMode === 'direct-registration' && CONFIG.SIGNUP.REQUIRES_APPROVAL === true) {
+    return { allowed: false }
+  }
+
   // No limit and signup is enabled
   if (CONFIG.SIGNUP.LIMIT === -1) {
     return { allowed: true }
index 10167ee38edf1afe80ef75dbbd1bac7694e71835..3a805a943d6ab5c01271c37807ae54cf4d0ac58e 100644 (file)
@@ -76,7 +76,7 @@ export async function synchronizeChannel (options: {
 
     await JobQueue.Instance.createJobWithChildren(parent, children)
   } catch (err) {
-    logger.error(`Failed to import channel ${channel.name}`, { err })
+    logger.error(`Failed to import ${externalChannelUrl} in channel ${channel.name}`, { err })
     channelSync.state = VideoChannelSyncState.FAILED
     await channelSync.save()
   }
index 2e433da0406564c67a919b4498bff1bbd6637034..ffb57944a3bccbc1ebb941a0a49d26ed466fb24a 100644 (file)
@@ -10,7 +10,7 @@ import { sequelizeTypescript } from '../initializers/database'
 import { AccountModel } from '../models/account/account'
 import { UserNotificationSettingModel } from '../models/user/user-notification-setting'
 import { MAccountDefault, MChannelActor } from '../types/models'
-import { MUser, MUserDefault, MUserId } from '../types/models/user'
+import { MRegistration, MUser, MUserDefault, MUserId } from '../types/models/user'
 import { generateAndSaveActorKeys } from './activitypub/actors'
 import { getLocalAccountActivityPubUrl } from './activitypub/url'
 import { Emailer } from './emailer'
@@ -97,7 +97,7 @@ async function createUserAccountAndChannelAndPlaylist (parameters: {
     })
     userCreated.Account = accountCreated
 
-    const channelAttributes = await buildChannelAttributes(userCreated, t, channelNames)
+    const channelAttributes = await buildChannelAttributes({ user: userCreated, transaction: t, channelNames })
     const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t)
 
     const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t)
@@ -160,15 +160,28 @@ async function createApplicationActor (applicationId: number) {
 // ---------------------------------------------------------------------------
 
 async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
-  const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id)
-  let url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString
+  const verificationString = await Redis.Instance.setUserVerifyEmailVerificationString(user.id)
+  let verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?userId=${user.id}&verificationString=${verificationString}`
 
-  if (isPendingEmail) url += '&isPendingEmail=true'
+  if (isPendingEmail) verifyEmailUrl += '&isPendingEmail=true'
+
+  const to = isPendingEmail
+    ? user.pendingEmail
+    : user.email
 
-  const email = isPendingEmail ? user.pendingEmail : user.email
   const username = user.username
 
-  Emailer.Instance.addVerifyEmailJob(username, email, url)
+  Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: false })
+}
+
+async function sendVerifyRegistrationEmail (registration: MRegistration) {
+  const verificationString = await Redis.Instance.setRegistrationVerifyEmailVerificationString(registration.id)
+  const verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?registrationId=${registration.id}&verificationString=${verificationString}`
+
+  const to = registration.email
+  const username = registration.username
+
+  Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: true })
 }
 
 // ---------------------------------------------------------------------------
@@ -232,7 +245,10 @@ export {
   createApplicationActor,
   createUserAccountAndChannelAndPlaylist,
   createLocalAccountWithoutKeys,
+
   sendVerifyUserEmail,
+  sendVerifyRegistrationEmail,
+
   isAbleToUploadVideo,
   buildUser
 }
@@ -264,7 +280,13 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
   return UserNotificationSettingModel.create(values, { transaction: t })
 }
 
-async function buildChannelAttributes (user: MUser, transaction?: Transaction, channelNames?: ChannelNames) {
+async function buildChannelAttributes (options: {
+  user: MUser
+  transaction?: Transaction
+  channelNames?: ChannelNames
+}) {
+  const { user, transaction, channelNames } = options
+
   if (channelNames) return channelNames
 
   const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction)
index 02f160fe833f0970932462b9b7466551f55900d1..6eb865f7f97793c88540c841901a28e88aed128a 100644 (file)
@@ -1,30 +1,41 @@
+import express from 'express'
 import { cloneDeep } from 'lodash'
 import * as Sequelize from 'sequelize'
-import express from 'express'
 import { logger } from '@server/helpers/logger'
 import { sequelizeTypescript } from '@server/initializers/database'
 import { ResultList } from '../../shared/models'
 import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
 import { VideoCommentModel } from '../models/video/video-comment'
-import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models'
+import {
+  MAccountDefault,
+  MComment,
+  MCommentFormattable,
+  MCommentOwnerVideo,
+  MCommentOwnerVideoReply,
+  MVideoFullLight
+} from '../types/models'
 import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
 import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
 import { Hooks } from './plugins/hooks'
 
-async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) {
-  const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
+async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
+  let videoCommentInstanceBefore: MCommentOwnerVideo
 
   await sequelizeTypescript.transaction(async t => {
-    if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
-      await sendDeleteVideoComment(videoCommentInstance, t)
+    const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
+
+    videoCommentInstanceBefore = cloneDeep(comment)
+
+    if (comment.isOwned() || comment.Video.isOwned()) {
+      await sendDeleteVideoComment(comment, t)
     }
 
-    videoCommentInstance.markAsDeleted()
+    comment.markAsDeleted()
 
-    await videoCommentInstance.save({ transaction: t })
-  })
+    await comment.save({ transaction: t })
 
-  logger.info('Video comment %d deleted.', videoCommentInstance.id)
+    logger.info('Video comment %d deleted.', comment.id)
+  })
 
   Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
 }
@@ -64,7 +75,7 @@ async function createVideoComment (obj: {
   return savedComment
 }
 
-function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree {
+function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
   // Comments are sorted by id ASC
   const comments = resultList.data
 
index c43085d167631d5bf08956a31c20aceb7dda922f..17aa29cdda707f9dc2e3893557d3e8aae3d389aa 100644 (file)
@@ -1,5 +1,7 @@
 import LRUCache from 'lru-cache'
 import { LRU_CACHE } from '@server/initializers/constants'
+import { MUserAccountUrl } from '@server/types/models'
+import { pick } from '@shared/core-utils'
 import { buildUUID } from '@shared/extra-utils'
 
 // ---------------------------------------------------------------------------
@@ -10,19 +12,22 @@ class VideoTokensManager {
 
   private static instance: VideoTokensManager
 
-  private readonly lruCache = new LRUCache<string, string>({
+  private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({
     max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
     ttl: LRU_CACHE.VIDEO_TOKENS.TTL
   })
 
   private constructor () {}
 
-  create (videoUUID: string) {
+  create (options: {
+    user: MUserAccountUrl
+    videoUUID: string
+  }) {
     const token = buildUUID()
 
     const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
 
-    this.lruCache.set(token, videoUUID)
+    this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
 
     return { token, expires }
   }
@@ -34,7 +39,16 @@ class VideoTokensManager {
     const value = this.lruCache.get(options.token)
     if (!value) return false
 
-    return value === options.videoUUID
+    return value.videoUUID === options.videoUUID
+  }
+
+  getUserFromToken (options: {
+    token: string
+  }) {
+    const value = this.lruCache.get(options.token)
+    if (!value) return undefined
+
+    return value.user
   }
 
   static get Instance () {
index 4588958988af88a759c35ebfbc7a6afb58ed0643..77a532276d26de286b44ccb7c8700d82a4270504 100644 (file)
@@ -1,5 +1,4 @@
 import express from 'express'
-import { SortType } from '../models/utils'
 
 const setDefaultSort = setDefaultSortFactory('-createdAt')
 const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
@@ -7,27 +6,7 @@ const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
 const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
 
 const setDefaultSearchSort = setDefaultSortFactory('-match')
-
-function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
-  const newSort: SortType = { sortModel: undefined, sortValue: '' }
-
-  if (!req.query.sort) req.query.sort = '-createdAt'
-
-  // Set model we want to sort onto
-  if (req.query.sort === '-createdAt' || req.query.sort === 'createdAt' ||
-      req.query.sort === '-id' || req.query.sort === 'id') {
-    // If we want to sort onto the BlacklistedVideos relation, we won't specify it in the query parameter...
-    newSort.sortModel = undefined
-  } else {
-    newSort.sortModel = 'Video'
-  }
-
-  newSort.sortValue = req.query.sort
-
-  req.query.sort = newSort
-
-  return next()
-}
+const setBlacklistSort = setDefaultSortFactory('-createdAt')
 
 // ---------------------------------------------------------------------------
 
index 3a7daa57329e8fc2a5f88e4bf67be31cd0bd043e..c2dbfadb75dc4a70560c27739597e858dfdf7371 100644 (file)
@@ -29,6 +29,7 @@ const customConfigUpdateValidator = [
   body('signup.enabled').isBoolean(),
   body('signup.limit').isInt(),
   body('signup.requiresEmailVerification').isBoolean(),
+  body('signup.requiresApproval').isBoolean(),
   body('signup.minimumAge').isInt(),
 
   body('admin.email').isEmail(),
index 9bc8887ff2b50e953edefe72b7f8f69eff3ed830..1d0964667ac00209ed18b408e19d0cc9492963bd 100644 (file)
@@ -21,8 +21,10 @@ export * from './server'
 export * from './sort'
 export * from './static'
 export * from './themes'
+export * from './user-email-verification'
 export * from './user-history'
 export * from './user-notifications'
+export * from './user-registrations'
 export * from './user-subscriptions'
 export * from './users'
 export * from './videos'
diff --git a/server/middlewares/validators/shared/user-registrations.ts b/server/middlewares/validators/shared/user-registrations.ts
new file mode 100644 (file)
index 0000000..dbc7dda
--- /dev/null
@@ -0,0 +1,60 @@
+import express from 'express'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
+import { MRegistration } from '@server/types/models'
+import { forceNumber, pick } from '@shared/core-utils'
+import { HttpStatusCode } from '@shared/models'
+
+function checkRegistrationIdExist (idArg: number | string, res: express.Response) {
+  const id = forceNumber(idArg)
+  return checkRegistrationExist(() => UserRegistrationModel.load(id), res)
+}
+
+function checkRegistrationEmailExist (email: string, res: express.Response, abortResponse = true) {
+  return checkRegistrationExist(() => UserRegistrationModel.loadByEmail(email), res, abortResponse)
+}
+
+async function checkRegistrationHandlesDoNotAlreadyExist (options: {
+  username: string
+  channelHandle: string
+  email: string
+  res: express.Response
+}) {
+  const { res } = options
+
+  const registration = await UserRegistrationModel.loadByEmailOrHandle(pick(options, [ 'username', 'email', 'channelHandle' ]))
+
+  if (registration) {
+    res.fail({
+      status: HttpStatusCode.CONFLICT_409,
+      message: 'Registration with this username, channel name or email already exists.'
+    })
+    return false
+  }
+
+  return true
+}
+
+async function checkRegistrationExist (finder: () => Promise<MRegistration>, res: express.Response, abortResponse = true) {
+  const registration = await finder()
+
+  if (!registration) {
+    if (abortResponse === true) {
+      res.fail({
+        status: HttpStatusCode.NOT_FOUND_404,
+        message: 'User not found'
+      })
+    }
+
+    return false
+  }
+
+  res.locals.userRegistration = registration
+  return true
+}
+
+export {
+  checkRegistrationIdExist,
+  checkRegistrationEmailExist,
+  checkRegistrationHandlesDoNotAlreadyExist,
+  checkRegistrationExist
+}
index b8f1436d331939c594087ab1fe27f939c5188602..030adc9f7a49fff9890e77ef4dda4c287530d7be 100644 (file)
@@ -14,7 +14,7 @@ function checkUserEmailExist (email: string, res: express.Response, abortRespons
   return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
 }
 
-async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
+async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) {
   const user = await UserModel.loadByUsernameOrEmail(username, email)
 
   if (user) {
@@ -58,6 +58,6 @@ async function checkUserExist (finder: () => Promise<MUserDefault>, res: express
 export {
   checkUserIdExist,
   checkUserEmailExist,
-  checkUserNameOrEmailDoesNotAlreadyExist,
+  checkUserNameOrEmailDoNotAlreadyExist,
   checkUserExist
 }
index ebbfc0a0a9242232c349dd070c612b4ac5d51549..0033a32ff1af454f6a7794ce3403e49965b5526a 100644 (file)
@@ -180,18 +180,16 @@ async function checkCanAccessVideoStaticFiles (options: {
     return checkCanSeeVideo(options)
   }
 
-  if (!video.hasPrivateStaticPath()) return true
-
   const videoFileToken = req.query.videoFileToken
-  if (!videoFileToken) {
-    res.sendStatus(HttpStatusCode.FORBIDDEN_403)
-    return false
-  }
+  if (videoFileToken && VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
+    const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
 
-  if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
+    res.locals.videoFileToken = { user }
     return true
   }
 
+  if (!video.hasPrivateStaticPath()) return true
+
   res.sendStatus(HttpStatusCode.FORBIDDEN_403)
   return false
 }
index 7d063910799f419d607724e7420a14040b8716b5..e6cc46317593772a68c19e22d2071ffd02f3827b 100644 (file)
@@ -1,9 +1,41 @@
 import express from 'express'
 import { query } from 'express-validator'
-
 import { SORTABLE_COLUMNS } from '../../initializers/constants'
 import { areValidationErrors } from './shared'
 
+export const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
+export const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
+export const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
+export const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
+export const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
+export const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
+export const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
+export const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
+export const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
+export const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
+export const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
+export const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
+export const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
+export const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
+export const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
+export const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
+export const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
+export const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
+export const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
+export const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
+export const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
+export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
+export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
+export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
+export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
+
+export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
+export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
+
+export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS)
+
+// ---------------------------------------------------------------------------
+
 function checkSortFactory (columns: string[], tags: string[] = []) {
   return checkSort(createSortableColumns(columns), tags)
 }
@@ -27,64 +59,3 @@ function createSortableColumns (sortableColumns: string[]) {
 
   return sortableColumns.concat(sortableColumnDesc)
 }
-
-const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
-const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
-const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
-const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
-const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
-const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
-const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
-const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
-const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
-const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
-const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
-const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
-const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
-const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
-const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
-const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
-const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
-const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
-const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
-const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
-const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
-const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
-const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
-const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
-const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
-
-const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
-const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
-
-// ---------------------------------------------------------------------------
-
-export {
-  adminUsersSortValidator,
-  abusesSortValidator,
-  videoChannelsSortValidator,
-  videoImportsSortValidator,
-  videoCommentsValidator,
-  videosSearchSortValidator,
-  videosSortValidator,
-  blacklistSortValidator,
-  accountsSortValidator,
-  instanceFollowersSortValidator,
-  instanceFollowingSortValidator,
-  jobsSortValidator,
-  videoCommentThreadsSortValidator,
-  videoRatesSortValidator,
-  userSubscriptionsSortValidator,
-  availablePluginsSortValidator,
-  videoChannelsSearchSortValidator,
-  accountsBlocklistSortValidator,
-  serversBlocklistSortValidator,
-  userNotificationsSortValidator,
-  videoPlaylistsSortValidator,
-  videoRedundanciesSortValidator,
-  videoPlaylistsSearchSortValidator,
-  accountsFollowersSortValidator,
-  videoChannelsFollowersSortValidator,
-  videoChannelSyncsSortValidator,
-  pluginsSortValidator
-}
diff --git a/server/middlewares/validators/user-email-verification.ts b/server/middlewares/validators/user-email-verification.ts
new file mode 100644 (file)
index 0000000..74702a8
--- /dev/null
@@ -0,0 +1,94 @@
+import express from 'express'
+import { body, param } from 'express-validator'
+import { toBooleanOrNull } from '@server/helpers/custom-validators/misc'
+import { HttpStatusCode } from '@shared/models'
+import { logger } from '../../helpers/logger'
+import { Redis } from '../../lib/redis'
+import { areValidationErrors, checkUserEmailExist, checkUserIdExist } from './shared'
+import { checkRegistrationEmailExist, checkRegistrationIdExist } from './shared/user-registrations'
+
+const usersAskSendVerifyEmailValidator = [
+  body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+
+    const [ userExists, registrationExists ] = await Promise.all([
+      checkUserEmailExist(req.body.email, res, false),
+      checkRegistrationEmailExist(req.body.email, res, false)
+    ])
+
+    if (!userExists && !registrationExists) {
+      logger.debug('User or registration with email %s does not exist (asking verify email).', req.body.email)
+      // Do not leak our emails
+      return res.status(HttpStatusCode.NO_CONTENT_204).end()
+    }
+
+    if (res.locals.user?.pluginAuth) {
+      return res.fail({
+        status: HttpStatusCode.CONFLICT_409,
+        message: 'Cannot ask verification email of a user that uses a plugin authentication.'
+      })
+    }
+
+    return next()
+  }
+]
+
+const usersVerifyEmailValidator = [
+  param('id')
+    .isInt().not().isEmpty().withMessage('Should have a valid id'),
+
+  body('verificationString')
+    .not().isEmpty().withMessage('Should have a valid verification string'),
+  body('isPendingEmail')
+    .optional()
+    .customSanitizer(toBooleanOrNull),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+    if (!await checkUserIdExist(req.params.id, res)) return
+
+    const user = res.locals.user
+    const redisVerificationString = await Redis.Instance.getUserVerifyEmailLink(user.id)
+
+    if (redisVerificationString !== req.body.verificationString) {
+      return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
+    }
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+const registrationVerifyEmailValidator = [
+  param('registrationId')
+    .isInt().not().isEmpty().withMessage('Should have a valid registrationId'),
+
+  body('verificationString')
+    .not().isEmpty().withMessage('Should have a valid verification string'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+    if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
+
+    const registration = res.locals.userRegistration
+    const redisVerificationString = await Redis.Instance.getRegistrationVerifyEmailLink(registration.id)
+
+    if (redisVerificationString !== req.body.verificationString) {
+      return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
+    }
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  usersAskSendVerifyEmailValidator,
+  usersVerifyEmailValidator,
+
+  registrationVerifyEmailValidator
+}
diff --git a/server/middlewares/validators/user-registrations.ts b/server/middlewares/validators/user-registrations.ts
new file mode 100644 (file)
index 0000000..fcf655a
--- /dev/null
@@ -0,0 +1,208 @@
+import express from 'express'
+import { body, param, query, ValidationChain } from 'express-validator'
+import { exists, isBooleanValid, isIdValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc'
+import { isRegistrationModerationResponseValid, isRegistrationReasonValid } from '@server/helpers/custom-validators/user-registration'
+import { CONFIG } from '@server/initializers/config'
+import { Hooks } from '@server/lib/plugins/hooks'
+import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState } from '@shared/models'
+import { isUserDisplayNameValid, isUserPasswordValid, isUserUsernameValid } from '../../helpers/custom-validators/users'
+import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
+import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../lib/signup'
+import { ActorModel } from '../../models/actor/actor'
+import { areValidationErrors, checkUserNameOrEmailDoNotAlreadyExist } from './shared'
+import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations'
+
+const usersDirectRegistrationValidator = usersCommonRegistrationValidatorFactory()
+
+const usersRequestRegistrationValidator = [
+  ...usersCommonRegistrationValidatorFactory([
+    body('registrationReason')
+      .custom(isRegistrationReasonValid)
+  ]),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    const body: UserRegistrationRequest = req.body
+
+    if (CONFIG.SIGNUP.REQUIRES_APPROVAL !== true) {
+      return res.fail({
+        status: HttpStatusCode.BAD_REQUEST_400,
+        message: 'Signup approval is not enabled on this instance'
+      })
+    }
+
+    const options = { username: body.username, email: body.email, channelHandle: body.channel?.name, res }
+    if (!await checkRegistrationHandlesDoNotAlreadyExist(options)) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+function ensureUserRegistrationAllowedFactory (signupMode: SignupMode) {
+  return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    const allowedParams = {
+      body: req.body,
+      ip: req.ip,
+      signupMode
+    }
+
+    const allowedResult = await Hooks.wrapPromiseFun(
+      isSignupAllowed,
+      allowedParams,
+
+      signupMode === 'direct-registration'
+        ? 'filter:api.user.signup.allowed.result'
+        : 'filter:api.user.request-signup.allowed.result'
+    )
+
+    if (allowedResult.allowed === false) {
+      return res.fail({
+        status: HttpStatusCode.FORBIDDEN_403,
+        message: allowedResult.errorMessage || 'User registration is not enabled, user limit is reached or registration requires approval.'
+      })
+    }
+
+    return next()
+  }
+}
+
+const ensureUserRegistrationAllowedForIP = [
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    const allowed = isSignupAllowedForCurrentIP(req.ip)
+
+    if (allowed === false) {
+      return res.fail({
+        status: HttpStatusCode.FORBIDDEN_403,
+        message: 'You are not on a network authorized for registration.'
+      })
+    }
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+const acceptOrRejectRegistrationValidator = [
+  param('registrationId')
+    .custom(isIdValid),
+
+  body('moderationResponse')
+    .custom(isRegistrationModerationResponseValid),
+
+  body('preventEmailDelivery')
+    .optional()
+    .customSanitizer(toBooleanOrNull)
+    .custom(isBooleanValid).withMessage('Should have preventEmailDelivery boolean'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+    if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
+
+    if (res.locals.userRegistration.state !== UserRegistrationState.PENDING) {
+      return res.fail({
+        status: HttpStatusCode.CONFLICT_409,
+        message: 'This registration is already accepted or rejected.'
+      })
+    }
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+const getRegistrationValidator = [
+  param('registrationId')
+    .custom(isIdValid),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+    if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+const listRegistrationsValidator = [
+  query('search')
+    .optional()
+    .custom(exists),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  usersDirectRegistrationValidator,
+  usersRequestRegistrationValidator,
+
+  ensureUserRegistrationAllowedFactory,
+  ensureUserRegistrationAllowedForIP,
+
+  getRegistrationValidator,
+  listRegistrationsValidator,
+
+  acceptOrRejectRegistrationValidator
+}
+
+// ---------------------------------------------------------------------------
+
+function usersCommonRegistrationValidatorFactory (additionalValidationChain: ValidationChain[] = []) {
+  return [
+    body('username')
+      .custom(isUserUsernameValid),
+    body('password')
+      .custom(isUserPasswordValid),
+    body('email')
+      .isEmail(),
+    body('displayName')
+      .optional()
+      .custom(isUserDisplayNameValid),
+
+    body('channel.name')
+      .optional()
+      .custom(isVideoChannelUsernameValid),
+    body('channel.displayName')
+      .optional()
+      .custom(isVideoChannelDisplayNameValid),
+
+    ...additionalValidationChain,
+
+    async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+      if (areValidationErrors(req, res, { omitBodyLog: true })) return
+
+      const body: UserRegister | UserRegistrationRequest = req.body
+
+      if (!await checkUserNameOrEmailDoNotAlreadyExist(body.username, body.email, res)) return
+
+      if (body.channel) {
+        if (!body.channel.name || !body.channel.displayName) {
+          return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
+        }
+
+        if (body.channel.name === body.username) {
+          return res.fail({ message: 'Channel name cannot be the same as user username.' })
+        }
+
+        const existing = await ActorModel.loadLocalByName(body.channel.name)
+        if (existing) {
+          return res.fail({
+            status: HttpStatusCode.CONFLICT_409,
+            message: `Channel with name ${body.channel.name} already exists.`
+          })
+        }
+      }
+
+      return next()
+    }
+  ]
+}
index 64bd9ca70ec4f92a468e5a85f12542702c657aeb..f7033f44a6c6605e26c93ed905bf34671fa28684 100644 (file)
@@ -1,8 +1,7 @@
 import express from 'express'
 import { body, param, query } from 'express-validator'
-import { Hooks } from '@server/lib/plugins/hooks'
 import { forceNumber } from '@shared/core-utils'
-import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models'
+import { HttpStatusCode, UserRight, UserRole } from '@shared/models'
 import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
 import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
 import {
@@ -24,17 +23,16 @@ import {
   isUserVideoQuotaValid,
   isUserVideosHistoryEnabledValid
 } from '../../helpers/custom-validators/users'
-import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
+import { isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
 import { logger } from '../../helpers/logger'
 import { isThemeRegistered } from '../../lib/plugins/theme-utils'
 import { Redis } from '../../lib/redis'
-import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
 import { ActorModel } from '../../models/actor/actor'
 import {
   areValidationErrors,
   checkUserEmailExist,
   checkUserIdExist,
-  checkUserNameOrEmailDoesNotAlreadyExist,
+  checkUserNameOrEmailDoNotAlreadyExist,
   doesVideoChannelIdExist,
   doesVideoExist,
   isValidVideoIdParam
@@ -81,7 +79,7 @@ const usersAddValidator = [
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res, { omitBodyLog: true })) return
-    if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return
+    if (!await checkUserNameOrEmailDoNotAlreadyExist(req.body.username, req.body.email, res)) return
 
     const authUser = res.locals.oauth.token.User
     if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) {
@@ -109,51 +107,6 @@ const usersAddValidator = [
   }
 ]
 
-const usersRegisterValidator = [
-  body('username')
-    .custom(isUserUsernameValid),
-  body('password')
-    .custom(isUserPasswordValid),
-  body('email')
-    .isEmail(),
-  body('displayName')
-    .optional()
-    .custom(isUserDisplayNameValid),
-
-  body('channel.name')
-    .optional()
-    .custom(isVideoChannelUsernameValid),
-  body('channel.displayName')
-    .optional()
-    .custom(isVideoChannelDisplayNameValid),
-
-  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    if (areValidationErrors(req, res, { omitBodyLog: true })) return
-    if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return
-
-    const body: UserRegister = req.body
-    if (body.channel) {
-      if (!body.channel.name || !body.channel.displayName) {
-        return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
-      }
-
-      if (body.channel.name === body.username) {
-        return res.fail({ message: 'Channel name cannot be the same as user username.' })
-      }
-
-      const existing = await ActorModel.loadLocalByName(body.channel.name)
-      if (existing) {
-        return res.fail({
-          status: HttpStatusCode.CONFLICT_409,
-          message: `Channel with name ${body.channel.name} already exists.`
-        })
-      }
-    }
-
-    return next()
-  }
-]
-
 const usersRemoveValidator = [
   param('id')
     .custom(isIdValid),
@@ -365,45 +318,6 @@ const usersVideosValidator = [
   }
 ]
 
-const ensureUserRegistrationAllowed = [
-  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    const allowedParams = {
-      body: req.body,
-      ip: req.ip
-    }
-
-    const allowedResult = await Hooks.wrapPromiseFun(
-      isSignupAllowed,
-      allowedParams,
-      'filter:api.user.signup.allowed.result'
-    )
-
-    if (allowedResult.allowed === false) {
-      return res.fail({
-        status: HttpStatusCode.FORBIDDEN_403,
-        message: allowedResult.errorMessage || 'User registration is not enabled or user limit is reached.'
-      })
-    }
-
-    return next()
-  }
-]
-
-const ensureUserRegistrationAllowedForIP = [
-  (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    const allowed = isSignupAllowedForCurrentIP(req.ip)
-
-    if (allowed === false) {
-      return res.fail({
-        status: HttpStatusCode.FORBIDDEN_403,
-        message: 'You are not on a network authorized for registration.'
-      })
-    }
-
-    return next()
-  }
-]
-
 const usersAskResetPasswordValidator = [
   body('email')
     .isEmail(),
@@ -455,58 +369,6 @@ const usersResetPasswordValidator = [
   }
 ]
 
-const usersAskSendVerifyEmailValidator = [
-  body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
-
-  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    if (areValidationErrors(req, res)) return
-
-    const exists = await checkUserEmailExist(req.body.email, res, false)
-    if (!exists) {
-      logger.debug('User with email %s does not exist (asking verify email).', req.body.email)
-      // Do not leak our emails
-      return res.status(HttpStatusCode.NO_CONTENT_204).end()
-    }
-
-    if (res.locals.user.pluginAuth) {
-      return res.fail({
-        status: HttpStatusCode.CONFLICT_409,
-        message: 'Cannot ask verification email of a user that uses a plugin authentication.'
-      })
-    }
-
-    return next()
-  }
-]
-
-const usersVerifyEmailValidator = [
-  param('id')
-    .isInt().not().isEmpty().withMessage('Should have a valid id'),
-
-  body('verificationString')
-    .not().isEmpty().withMessage('Should have a valid verification string'),
-  body('isPendingEmail')
-    .optional()
-    .customSanitizer(toBooleanOrNull),
-
-  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    if (areValidationErrors(req, res)) return
-    if (!await checkUserIdExist(req.params.id, res)) return
-
-    const user = res.locals.user
-    const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id)
-
-    if (redisVerificationString !== req.body.verificationString) {
-      return res.fail({
-        status: HttpStatusCode.FORBIDDEN_403,
-        message: 'Invalid verification string.'
-      })
-    }
-
-    return next()
-  }
-]
-
 const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => {
   return [
     body('currentPassword').optional().custom(exists),
@@ -603,21 +465,16 @@ export {
   usersListValidator,
   usersAddValidator,
   deleteMeValidator,
-  usersRegisterValidator,
   usersBlockingValidator,
   usersRemoveValidator,
   usersUpdateValidator,
   usersUpdateMeValidator,
   usersVideoRatingValidator,
   usersCheckCurrentPasswordFactory,
-  ensureUserRegistrationAllowed,
-  ensureUserRegistrationAllowedForIP,
   usersGetValidator,
   usersVideosValidator,
   usersAskResetPasswordValidator,
   usersResetPasswordValidator,
-  usersAskSendVerifyEmailValidator,
-  usersVerifyEmailValidator,
   userAutocompleteValidator,
   ensureAuthUserOwnsAccountValidator,
   ensureCanModerateUser,
index 20008768be91dfbfa2936e4ba75e8f836050403e..14a5bffa28707fd0c9e28bc05c225d83339d57ce 100644 (file)
@@ -5,7 +5,7 @@ import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
 import { AbuseMessage } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 import { AbuseModel } from './abuse'
 
 @Table({
index 4c6a96a86d3f882c0afd06ca5035aa2118d8f3d9..4ce40bf2f389736210d25f05306cb86e4447447e 100644 (file)
@@ -34,13 +34,13 @@ import { AttributesOnly } from '@shared/typescript-utils'
 import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
 import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 import { ThumbnailModel } from '../video/thumbnail'
 import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
 import { VideoBlacklistModel } from '../video/video-blacklist'
 import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
 import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
-import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder'
+import { buildAbuseListQuery, BuildAbusesQueryOptions } from './sql/abuse-query-builder'
 import { VideoAbuseModel } from './video-abuse'
 import { VideoCommentAbuseModel } from './video-comment-abuse'
 
similarity index 97%
rename from server/models/abuse/abuse-query-builder.ts
rename to server/models/abuse/sql/abuse-query-builder.ts
index 74f4542e55259ff20a47e94bb1e080de3f094ced..282d4541a3e4c4a6cdee706b4087d8832dc4822a 100644 (file)
@@ -2,7 +2,7 @@
 import { exists } from '@server/helpers/custom-validators/misc'
 import { forceNumber } from '@shared/core-utils'
 import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
-import { buildBlockedAccountSQL, buildDirectionAndField } from '../utils'
+import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared'
 
 export type BuildAbusesQueryOptions = {
   start: number
@@ -157,7 +157,7 @@ function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' |
 }
 
 function buildAbuseOrder (value: string) {
-  const { direction, field } = buildDirectionAndField(value)
+  const { direction, field } = buildSortDirectionAndField(value)
 
   return `ORDER BY "abuse"."${field}" ${direction}`
 }
index 377249b38608ba565a9270c1a814cb202bf54899..f6212ff6e9a11e43c5ca8bb5b7f58ac44b7f7135 100644 (file)
@@ -6,7 +6,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
 import { AccountBlock } from '../../../shared/models'
 import { ActorModel } from '../actor/actor'
 import { ServerModel } from '../server/server'
-import { createSafeIn, getSort, searchAttribute } from '../utils'
+import { createSafeIn, getSort, searchAttribute } from '../shared'
 import { AccountModel } from './account'
 
 @Table({
index 7afc907da2b7c9a47ac606dae2e1afd888c1d406..9e7ef4394b07b20695896572f31ec44f5528f269 100644 (file)
@@ -11,7 +11,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
 import { ActorModel } from '../actor/actor'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
 import { AccountModel } from './account'
index 8a7dfba9454d1327dc90648218eaee6728df5018..dc989417bf2ab4c9aea8020e3127584e645c90d4 100644 (file)
@@ -16,7 +16,7 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { Account, AccountSummary } from '../../../shared/models/actors'
 import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
@@ -38,7 +38,7 @@ import { ApplicationModel } from '../application/application'
 import { ServerModel } from '../server/server'
 import { ServerBlocklistModel } from '../server/server-blocklist'
 import { UserModel } from '../user/user'
-import { getSort, throwIfNotValid } from '../utils'
+import { buildSQLAttributes, getSort, throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { VideoChannelModel } from '../video/video-channel'
 import { VideoCommentModel } from '../video/video-comment'
@@ -251,6 +251,18 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
     return undefined
   }
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static load (id: number, transaction?: Transaction): Promise<MAccountDefault> {
     return AccountModel.findByPk(id, { transaction })
   }
index 9615229dd7ea3a332262e3f2a31951e42d29ab7f..32e5d78b0b2fa61c252bcaf02df0c8bbd0151e18 100644 (file)
@@ -38,7 +38,7 @@ import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAM
 import { AccountModel } from '../account/account'
 import { ServerModel } from '../server/server'
 import { doesExist } from '../shared/query'
-import { createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../utils'
+import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared'
 import { VideoChannelModel } from '../video/video-channel'
 import { ActorModel, unusedActorAttributesForAPI } from './actor'
 import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder'
@@ -140,6 +140,18 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
     })
   }
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   /*
    * @deprecated Use `findOrCreateCustom` instead
   */
@@ -213,7 +225,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
       `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
       `LIMIT 1`
 
-    return doesExist(query, { actorId, followerActorId })
+    return doesExist(this.sequelize, query, { actorId, followerActorId })
   }
 
   static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
index f2b3b2f4b33672052c05658d138e101526d14768..9c34a0101acf46c778f548933f7ed924d756d5a0 100644 (file)
@@ -22,7 +22,7 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
 import { logger } from '../../helpers/logger'
 import { CONFIG } from '../../initializers/config'
 import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
-import { throwIfNotValid } from '../utils'
+import { buildSQLAttributes, throwIfNotValid } from '../shared'
 import { ActorModel } from './actor'
 
 @Table({
@@ -94,6 +94,18 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
       .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err }))
   }
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static loadByName (filename: string) {
     const query = {
       where: {
index d7afa727d2e50185f4d4ef817f287c0ac3174e22..1432e87574468e493a8d9ec3e225c4c6b927e4dd 100644 (file)
@@ -17,7 +17,7 @@ import {
 } from 'sequelize-typescript'
 import { activityPubContextify } from '@server/lib/activitypub/context'
 import { getBiggestActorImage } from '@server/lib/actor-image'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
 import { forceNumber, getLowercaseExtension } from '@shared/core-utils'
 import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
@@ -55,7 +55,7 @@ import {
 import { AccountModel } from '../account/account'
 import { getServerActor } from '../application/application'
 import { ServerModel } from '../server/server'
-import { isOutdated, throwIfNotValid } from '../utils'
+import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { VideoChannelModel } from '../video/video-channel'
 import { ActorFollowModel } from './actor-follow'
@@ -65,7 +65,7 @@ enum ScopeNames {
   FULL = 'FULL'
 }
 
-export const unusedActorAttributesForAPI = [
+export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] = [
   'publicKey',
   'privateKey',
   'inboxUrl',
@@ -306,6 +306,27 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
   })
   VideoChannel: VideoChannelModel
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix,
+      excludeAttributes: unusedActorAttributesForAPI
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static async load (id: number): Promise<MActor> {
     const actorServer = await getServerActor()
     if (id === actorServer.id) return actorServer
index 4a17a8f11213a23bef7dd252da6a58226bb2b35e..34ce29b5dcacb7edddcc5dd0b8b3d2dc881c4ccd 100644 (file)
@@ -1,8 +1,8 @@
 import { Sequelize } from 'sequelize'
 import { ModelBuilder } from '@server/models/shared'
-import { parseRowCountResult } from '@server/models/utils'
 import { MActorFollowActorsDefault } from '@server/types/models'
 import { ActivityPubActorType, FollowState } from '@shared/models'
+import { parseRowCountResult } from '../../shared'
 import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
 
 export interface ListFollowersOptions {
index 880170b857db4c7f34ade557fc604cfea3324e53..77b4e3dce2599b82487c8fa74718107f62d8f266 100644 (file)
@@ -1,8 +1,8 @@
 import { Sequelize } from 'sequelize'
 import { ModelBuilder } from '@server/models/shared'
-import { parseRowCountResult } from '@server/models/utils'
 import { MActorFollowActorsDefault } from '@server/types/models'
 import { ActivityPubActorType, FollowState } from '@shared/models'
+import { parseRowCountResult } from '../../shared'
 import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
 
 export interface ListFollowingOptions {
index 156b37d44d56d77f7cdf68d8166c3d0307076b3e..7dd908ece9b12bab51f41bbb7fe5879ab753b9e3 100644 (file)
@@ -1,62 +1,31 @@
+import { logger } from '@server/helpers/logger'
+import { Memoize } from '@server/helpers/memoize'
+import { ServerModel } from '@server/models/server/server'
+import { ActorModel } from '../../actor'
+import { ActorFollowModel } from '../../actor-follow'
+import { ActorImageModel } from '../../actor-image'
+
 export class ActorFollowTableAttributes {
 
+  @Memoize()
   getFollowAttributes () {
-    return [
-      '"ActorFollowModel"."id"',
-      '"ActorFollowModel"."state"',
-      '"ActorFollowModel"."score"',
-      '"ActorFollowModel"."url"',
-      '"ActorFollowModel"."actorId"',
-      '"ActorFollowModel"."targetActorId"',
-      '"ActorFollowModel"."createdAt"',
-      '"ActorFollowModel"."updatedAt"'
-    ].join(', ')
+    logger.error('coucou')
+
+    return ActorFollowModel.getSQLAttributes('ActorFollowModel').join(', ')
   }
 
+  @Memoize()
   getActorAttributes (actorTableName: string) {
-    return [
-      `"${actorTableName}"."id" AS "${actorTableName}.id"`,
-      `"${actorTableName}"."type" AS "${actorTableName}.type"`,
-      `"${actorTableName}"."preferredUsername" AS "${actorTableName}.preferredUsername"`,
-      `"${actorTableName}"."url" AS "${actorTableName}.url"`,
-      `"${actorTableName}"."publicKey" AS "${actorTableName}.publicKey"`,
-      `"${actorTableName}"."privateKey" AS "${actorTableName}.privateKey"`,
-      `"${actorTableName}"."followersCount" AS "${actorTableName}.followersCount"`,
-      `"${actorTableName}"."followingCount" AS "${actorTableName}.followingCount"`,
-      `"${actorTableName}"."inboxUrl" AS "${actorTableName}.inboxUrl"`,
-      `"${actorTableName}"."outboxUrl" AS "${actorTableName}.outboxUrl"`,
-      `"${actorTableName}"."sharedInboxUrl" AS "${actorTableName}.sharedInboxUrl"`,
-      `"${actorTableName}"."followersUrl" AS "${actorTableName}.followersUrl"`,
-      `"${actorTableName}"."followingUrl" AS "${actorTableName}.followingUrl"`,
-      `"${actorTableName}"."remoteCreatedAt" AS "${actorTableName}.remoteCreatedAt"`,
-      `"${actorTableName}"."serverId" AS "${actorTableName}.serverId"`,
-      `"${actorTableName}"."createdAt" AS "${actorTableName}.createdAt"`,
-      `"${actorTableName}"."updatedAt" AS "${actorTableName}.updatedAt"`
-    ].join(', ')
+    return ActorModel.getSQLAttributes(actorTableName, `${actorTableName}.`).join(', ')
   }
 
+  @Memoize()
   getServerAttributes (actorTableName: string) {
-    return [
-      `"${actorTableName}->Server"."id" AS "${actorTableName}.Server.id"`,
-      `"${actorTableName}->Server"."host" AS "${actorTableName}.Server.host"`,
-      `"${actorTableName}->Server"."redundancyAllowed" AS "${actorTableName}.Server.redundancyAllowed"`,
-      `"${actorTableName}->Server"."createdAt" AS "${actorTableName}.Server.createdAt"`,
-      `"${actorTableName}->Server"."updatedAt" AS "${actorTableName}.Server.updatedAt"`
-    ].join(', ')
+    return ServerModel.getSQLAttributes(`${actorTableName}->Server`, `${actorTableName}.Server.`).join(', ')
   }
 
+  @Memoize()
   getAvatarAttributes (actorTableName: string) {
-    return [
-      `"${actorTableName}->Avatars"."id" AS "${actorTableName}.Avatars.id"`,
-      `"${actorTableName}->Avatars"."filename" AS "${actorTableName}.Avatars.filename"`,
-      `"${actorTableName}->Avatars"."height" AS "${actorTableName}.Avatars.height"`,
-      `"${actorTableName}->Avatars"."width" AS "${actorTableName}.Avatars.width"`,
-      `"${actorTableName}->Avatars"."fileUrl" AS "${actorTableName}.Avatars.fileUrl"`,
-      `"${actorTableName}->Avatars"."onDisk" AS "${actorTableName}.Avatars.onDisk"`,
-      `"${actorTableName}->Avatars"."type" AS "${actorTableName}.Avatars.type"`,
-      `"${actorTableName}->Avatars"."actorId" AS "${actorTableName}.Avatars.actorId"`,
-      `"${actorTableName}->Avatars"."createdAt" AS "${actorTableName}.Avatars.createdAt"`,
-      `"${actorTableName}->Avatars"."updatedAt" AS "${actorTableName}.Avatars.updatedAt"`
-    ].join(', ')
+    return ActorImageModel.getSQLAttributes(`${actorTableName}->Avatars`, `${actorTableName}.Avatars.`).join(', ')
   }
 }
index 1d70fbe702e8e5377b548d5aaadcfb9ddf2c21b7..d9593e48b8fa911c1ccbab43a14f377eae28549e 100644 (file)
@@ -1,7 +1,7 @@
 import { Sequelize } from 'sequelize'
 import { AbstractRunQuery } from '@server/models/shared'
-import { getInstanceFollowsSort } from '@server/models/utils'
 import { ActorImageType } from '@shared/models'
+import { getInstanceFollowsSort } from '../../../shared'
 import { ActorFollowTableAttributes } from './actor-follow-table-attributes'
 
 type BaseOptions = {
index 15909d5f32154fee2dc6b1bd038f8532d55d2cee..c2a72b71f42d554bae801ca1e106f88a741d2bd8 100644 (file)
@@ -34,7 +34,7 @@ import { CONFIG } from '../../initializers/config'
 import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
 import { ActorModel } from '../actor/actor'
 import { ServerModel } from '../server/server'
-import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
+import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../shared'
 import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
 import { VideoModel } from '../video/video'
 import { VideoChannelModel } from '../video/video-channel'
index 71c205ffaa2fd1b1904c9e7b118f29e3ef0e6aab..9948c9f7ac27d3366acd5f6dbac41767d1c5144f 100644 (file)
@@ -11,7 +11,7 @@ import {
   isPluginStableVersionValid,
   isPluginTypeValid
 } from '../../helpers/custom-validators/plugins'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 
 @DefaultScope(() => ({
   attributes: {
index 9752dfbc3f0a0c3330cd17364194decd765146b9..3d755fe4a1e40f71864945ac9f568dbc1150e293 100644 (file)
@@ -4,7 +4,7 @@ import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormat
 import { ServerBlock } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { AccountModel } from '../account/account'
-import { createSafeIn, getSort, searchAttribute } from '../utils'
+import { createSafeIn, getSort, searchAttribute } from '../shared'
 import { ServerModel } from './server'
 
 enum ScopeNames {
index ef42de09063b0f5ed7bf9d511e7d7d01c4fe65cf..a5e05f460fffcfe90c8026321f6df023c513b121 100644 (file)
@@ -4,7 +4,7 @@ import { MServer, MServerFormattable } from '@server/types/models/server'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { isHostValid } from '../../helpers/custom-validators/servers'
 import { ActorModel } from '../actor/actor'
-import { throwIfNotValid } from '../utils'
+import { buildSQLAttributes, throwIfNotValid } from '../shared'
 import { ServerBlocklistModel } from './server-blocklist'
 
 @Table({
@@ -52,6 +52,18 @@ export class ServerModel extends Model<Partial<AttributesOnly<ServerModel>>> {
   })
   BlockedBy: ServerBlocklistModel[]
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static load (id: number, transaction?: Transaction): Promise<MServer> {
     const query = {
       where: {
index 04528929c71351946da4bec8ede3500634bb2245..5a7621e4df4e43205ccd62b23d8e61f08701cf90 100644 (file)
@@ -1,4 +1,8 @@
 export * from './abstract-run-query'
 export * from './model-builder'
+export * from './model-cache'
 export * from './query'
+export * from './sequelize-helpers'
+export * from './sort'
+export * from './sql'
 export * from './update'
index c015ca4f5eeb9aa8bb66e3ad7ae757700d0224b8..07f7c40386b19e14c535b868c6ed4c6646f6b27e 100644 (file)
@@ -1,7 +1,24 @@
 import { isPlainObject } from 'lodash'
-import { Model as SequelizeModel, Sequelize } from 'sequelize'
+import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
 import { logger } from '@server/helpers/logger'
 
+/**
+ *
+ * Build Sequelize models from sequelize raw query (that must use { nest: true } options)
+ *
+ * In order to sequelize to correctly build the JSON this class will ingest,
+ * the columns selected in the raw query should be in the following form:
+ *   * All tables must be Pascal Cased (for example "VideoChannel")
+ *   * Root table must end with `Model` (for example "VideoCommentModel")
+ *   * Joined tables must contain the origin table name + '->JoinedTable'. For example:
+ *     * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
+ *     * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
+ *   * Selected columns must be renamed to contain the JSON path:
+ *     * "videoComment"."id": "VideoCommentModel"."id"
+ *     * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
+ *   * All tables must contain the row id
+ */
+
 export class ModelBuilder <T extends SequelizeModel> {
   private readonly modelRegistry = new Map<string, T>()
 
@@ -72,18 +89,18 @@ export class ModelBuilder <T extends SequelizeModel> {
         'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
         { existing: this.sequelize.modelManager.all.map(m => m.name) }
       )
-      return undefined
+      return { created: false, model: null }
     }
 
-    // FIXME: typings
-    const model = new (Model as any)(json)
+    const model = Model.build(json, { raw: true, isNewRecord: false })
+
     this.modelRegistry.set(registryKey, model)
 
     return { created: true, model }
   }
 
   private findModelBuilder (modelName: string) {
-    return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName))
+    return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
   }
 
   private buildSequelizeModelName (modelName: string) {
index 036cc13c6aec131168ef5551cc7b5d7fea3732b5..934acc21f7c364aca4e27fcfebb4498f9019d5d0 100644 (file)
@@ -1,17 +1,82 @@
-import { BindOrReplacements, QueryTypes } from 'sequelize'
-import { sequelizeTypescript } from '@server/initializers/database'
+import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize'
+import validator from 'validator'
+import { forceNumber } from '@shared/core-utils'
 
-function doesExist (query: string, bind?: BindOrReplacements) {
+function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) {
   const options = {
     type: QueryTypes.SELECT as QueryTypes.SELECT,
     bind,
     raw: true
   }
 
-  return sequelizeTypescript.query(query, options)
+  return sequelize.query(query, options)
             .then(results => results.length === 1)
 }
 
+function createSimilarityAttribute (col: string, value: string) {
+  return Sequelize.fn(
+    'similarity',
+
+    searchTrigramNormalizeCol(col),
+
+    searchTrigramNormalizeValue(value)
+  )
+}
+
+function buildWhereIdOrUUID (id: number | string) {
+  return validator.isInt('' + id) ? { id } : { uuid: id }
+}
+
+function parseAggregateResult (result: any) {
+  if (!result) return 0
+
+  const total = forceNumber(result)
+  if (isNaN(total)) return 0
+
+  return total
+}
+
+function parseRowCountResult (result: any) {
+  if (result.length !== 0) return result[0].total
+
+  return 0
+}
+
+function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
+  return toEscape.map(t => {
+    return t === null
+      ? null
+      : sequelize.escape('' + t)
+  }).concat(additionalUnescaped).join(', ')
+}
+
+function searchAttribute (sourceField?: string, targetField?: string) {
+  if (!sourceField) return {}
+
+  return {
+    [targetField]: {
+      // FIXME: ts error
+      [Op.iLike as any]: `%${sourceField}%`
+    }
+  }
+}
+
 export {
-  doesExist
+  doesExist,
+  createSimilarityAttribute,
+  buildWhereIdOrUUID,
+  parseAggregateResult,
+  parseRowCountResult,
+  createSafeIn,
+  searchAttribute
+}
+
+// ---------------------------------------------------------------------------
+
+function searchTrigramNormalizeValue (value: string) {
+  return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
+}
+
+function searchTrigramNormalizeCol (col: string) {
+  return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
 }
diff --git a/server/models/shared/sequelize-helpers.ts b/server/models/shared/sequelize-helpers.ts
new file mode 100644 (file)
index 0000000..7af8471
--- /dev/null
@@ -0,0 +1,39 @@
+import { Sequelize } from 'sequelize'
+
+function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
+  if (!model.createdAt || !model.updatedAt) {
+    throw new Error('Miss createdAt & updatedAt attributes to model')
+  }
+
+  const now = Date.now()
+  const createdAtTime = model.createdAt.getTime()
+  const updatedAtTime = model.updatedAt.getTime()
+
+  return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
+}
+
+function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
+  if (nullable && (value === null || value === undefined)) return
+
+  if (validator(value) === false) {
+    throw new Error(`"${value}" is not a valid ${fieldName}.`)
+  }
+}
+
+function buildTrigramSearchIndex (indexName: string, attribute: string) {
+  return {
+    name: indexName,
+    // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
+    fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
+    using: 'gin',
+    operator: 'gin_trgm_ops'
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  throwIfNotValid,
+  buildTrigramSearchIndex,
+  isOutdated
+}
diff --git a/server/models/shared/sort.ts b/server/models/shared/sort.ts
new file mode 100644 (file)
index 0000000..d923072
--- /dev/null
@@ -0,0 +1,146 @@
+import { literal, OrderItem, Sequelize } from 'sequelize'
+
+// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
+function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  let finalField: string | ReturnType<typeof Sequelize.col>
+
+  if (field.toLowerCase() === 'match') { // Search
+    finalField = Sequelize.col('similarity')
+  } else {
+    finalField = field
+  }
+
+  return [ [ finalField, direction ], lastSort ]
+}
+
+function getAdminUsersSort (value: string): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  let finalField: string | ReturnType<typeof Sequelize.col>
+
+  if (field === 'videoQuotaUsed') { // Users list
+    finalField = Sequelize.col('videoQuotaUsed')
+  } else {
+    finalField = field
+  }
+
+  const nullPolicy = direction === 'ASC'
+    ? 'NULLS FIRST'
+    : 'NULLS LAST'
+
+  // FIXME: typings
+  return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
+}
+
+function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  if (field.toLowerCase() === 'name') {
+    return [ [ 'displayName', direction ], lastSort ]
+  }
+
+  return getSort(value, lastSort)
+}
+
+function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  if (field.toLowerCase() === 'trending') { // Sort by aggregation
+    return [
+      [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
+
+      [ Sequelize.col('VideoModel.views'), direction ],
+
+      lastSort
+    ]
+  } else if (field === 'publishedAt') {
+    return [
+      [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
+
+      [ Sequelize.col('VideoModel.publishedAt'), direction ],
+
+      lastSort
+    ]
+  }
+
+  let finalField: string | ReturnType<typeof Sequelize.col>
+
+  // Alias
+  if (field.toLowerCase() === 'match') { // Search
+    finalField = Sequelize.col('similarity')
+  } else {
+    finalField = field
+  }
+
+  const firstSort: OrderItem = typeof finalField === 'string'
+    ? finalField.split('.').concat([ direction ]) as OrderItem
+    : [ finalField, direction ]
+
+  return [ firstSort, lastSort ]
+}
+
+function getBlacklistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  const videoFields = new Set([ 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid' ])
+
+  if (videoFields.has(field)) {
+    return [
+      [ literal(`"Video.${field}" ${direction}`) ],
+      lastSort
+    ] as OrderItem[]
+  }
+
+  return getSort(value, lastSort)
+}
+
+function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  if (field === 'redundancyAllowed') {
+    return [
+      [ 'ActorFollowing.Server.redundancyAllowed', direction ],
+      lastSort
+    ]
+  }
+
+  return getSort(value, lastSort)
+}
+
+function getChannelSyncSort (value: string): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+  if (field.toLowerCase() === 'videochannel') {
+    return [
+      [ literal('"VideoChannel.name"'), direction ]
+    ]
+  }
+  return [ [ field, direction ] ]
+}
+
+function buildSortDirectionAndField (value: string) {
+  let field: string
+  let direction: 'ASC' | 'DESC'
+
+  if (value.substring(0, 1) === '-') {
+    direction = 'DESC'
+    field = value.substring(1)
+  } else {
+    direction = 'ASC'
+    field = value
+  }
+
+  return { direction, field }
+}
+
+export {
+  buildSortDirectionAndField,
+  getPlaylistSort,
+  getSort,
+  getAdminUsersSort,
+  getVideoSort,
+  getBlacklistSort,
+  getChannelSyncSort,
+  getInstanceFollowsSort
+}
diff --git a/server/models/shared/sql.ts b/server/models/shared/sql.ts
new file mode 100644 (file)
index 0000000..5aaeb49
--- /dev/null
@@ -0,0 +1,68 @@
+import { literal, Model, ModelStatic } from 'sequelize'
+import { forceNumber } from '@shared/core-utils'
+import { AttributesOnly } from '@shared/typescript-utils'
+
+function buildLocalAccountIdsIn () {
+  return literal(
+    '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
+  )
+}
+
+function buildLocalActorIdsIn () {
+  return literal(
+    '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
+  )
+}
+
+function buildBlockedAccountSQL (blockerIds: number[]) {
+  const blockerIdsString = blockerIds.join(', ')
+
+  return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
+    ' UNION ' +
+    'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
+    'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
+    'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
+}
+
+function buildServerIdsFollowedBy (actorId: any) {
+  const actorIdNumber = forceNumber(actorId)
+
+  return '(' +
+    'SELECT "actor"."serverId" FROM "actorFollow" ' +
+    'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
+    'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
+    ')'
+}
+
+function buildSQLAttributes<M extends Model> (options: {
+  model: ModelStatic<M>
+  tableName: string
+
+  excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[]
+  aliasPrefix?: string
+}) {
+  const { model, tableName, aliasPrefix, excludeAttributes } = options
+
+  const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[]
+
+  return attributes
+    .filter(a => {
+      if (!excludeAttributes) return true
+      if (excludeAttributes.includes(a)) return false
+
+      return true
+    })
+    .map(a => {
+      return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"`
+    })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  buildSQLAttributes,
+  buildBlockedAccountSQL,
+  buildServerIdsFollowedBy,
+  buildLocalAccountIdsIn,
+  buildLocalActorIdsIn
+}
index d338211e380bbc5cf27cf15148df1d09ec932016..d02c4535dc5ec02783d6bdd2891c3f01df23a30c 100644 (file)
@@ -1,9 +1,15 @@
-import { QueryTypes, Transaction } from 'sequelize'
-import { sequelizeTypescript } from '@server/initializers/database'
+import { QueryTypes, Sequelize, Transaction } from 'sequelize'
 
 // Sequelize always skip the update if we only update updatedAt field
-function setAsUpdated (table: string, id: number, transaction?: Transaction) {
-  return sequelizeTypescript.query(
+function setAsUpdated (options: {
+  sequelize: Sequelize
+  table: string
+  id: number
+  transaction?: Transaction
+}) {
+  const { sequelize, table, id, transaction } = options
+
+  return sequelize.query(
     `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`,
     {
       replacements: { table, id, updatedAt: new Date() },
index 31b4932bf47466c5d8159b8582d2defbe3978c90..7b29807a36bb656bbeb3abab0d4a8e95c43eeabd 100644 (file)
@@ -1,8 +1,8 @@
 import { Sequelize } from 'sequelize'
 import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
-import { getSort } from '@server/models/utils'
 import { UserNotificationModelForApi } from '@server/types/models'
 import { ActorImageType } from '@shared/models'
+import { getSort } from '../../shared'
 
 export interface ListNotificationsOptions {
   userId: number
@@ -180,7 +180,9 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
       "Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type",
       "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename",
       "Account->Actor->Server"."id" AS "Account.Actor.Server.id",
-      "Account->Actor->Server"."host" AS "Account.Actor.Server.host"`
+      "Account->Actor->Server"."host" AS "Account.Actor.Server.host",
+      "UserRegistration"."id" AS "UserRegistration.id",
+      "UserRegistration"."username" AS "UserRegistration.username"`
   }
 
   private getJoins () {
@@ -196,74 +198,76 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
         ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id"
     ) ON "UserNotificationModel"."videoId" = "Video"."id"
 
-  LEFT JOIN (
-    "videoComment" AS "VideoComment"
-    INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
-    INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
-    LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
-      ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
-      AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
-    LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
-      ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
-    INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
-  ) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
+    LEFT JOIN (
+      "videoComment" AS "VideoComment"
+      INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
+      INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
+      LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
+        ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
+        AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
+      LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
+        ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
+      INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
+    ) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
+
+    LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
+    LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
+    LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
+    LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
+    LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
+      ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
+    LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
+      ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
+    LEFT JOIN (
+      "account" AS "Abuse->FlaggedAccount"
+      INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
+      LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
+        ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
+        AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
+      LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
+        ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
+    ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
 
-  LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
-  LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
-  LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
-  LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
-  LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
-    ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
-  LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
-    ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
-  LEFT JOIN (
-    "account" AS "Abuse->FlaggedAccount"
-    INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
-    LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
-      ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
-      AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
-    LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
-      ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
-  ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
+    LEFT JOIN (
+      "videoBlacklist" AS "VideoBlacklist"
+      INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
+    ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
 
-  LEFT JOIN (
-    "videoBlacklist" AS "VideoBlacklist"
-    INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
-  ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
+    LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
+    LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
 
-  LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
-  LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
+    LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
 
-  LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
+    LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
 
-  LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
+    LEFT JOIN (
+      "actorFollow" AS "ActorFollow"
+      INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
+      INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
+        ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
+      LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
+        ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
+        AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
+      LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
+        ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
+      INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
+      LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
+        ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
+      LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
+        ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
+      LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
+        ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
+    ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
 
-  LEFT JOIN (
-    "actorFollow" AS "ActorFollow"
-    INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
-    INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
-      ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
-    LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
-      ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
-      AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
-    LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
-      ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
-    INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
-    LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
-      ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
-    LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
-      ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
-    LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
-      ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
-  ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
+    LEFT JOIN (
+      "account" AS "Account"
+      INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
+      LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
+        ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
+        AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
+      LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
+    ) ON "UserNotificationModel"."accountId" = "Account"."id"
 
-  LEFT JOIN (
-    "account" AS "Account"
-    INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
-    LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
-      ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
-      AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
-    LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
-  ) ON "UserNotificationModel"."accountId" = "Account"."id"`
+    LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"`
   }
 }
index 66e1d85b31fe10476e0d511f2b677144b4bc8fa9..394494c0ce6c0f0a99950f1a12c32dcaa6e6ef0c 100644 (file)
@@ -17,7 +17,7 @@ import { MNotificationSettingFormattable } from '@server/types/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
 import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid } from '../shared'
 import { UserModel } from './user'
 
 @Table({
index d37fa5dc7129fc7f37e959e247451e1ecf1a3302..667ee7f5f8e6e917d9157320e18e8190eed7930d 100644 (file)
@@ -13,13 +13,14 @@ import { AccountModel } from '../account/account'
 import { ActorFollowModel } from '../actor/actor-follow'
 import { ApplicationModel } from '../application/application'
 import { PluginModel } from '../server/plugin'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { VideoBlacklistModel } from '../video/video-blacklist'
 import { VideoCommentModel } from '../video/video-comment'
 import { VideoImportModel } from '../video/video-import'
 import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
 import { UserModel } from './user'
+import { UserRegistrationModel } from './user-registration'
 
 @Table({
   tableName: 'userNotification',
@@ -98,6 +99,14 @@ import { UserModel } from './user'
           [Op.ne]: null
         }
       }
+    },
+    {
+      fields: [ 'userRegistrationId' ],
+      where: {
+        userRegistrationId: {
+          [Op.ne]: null
+        }
+      }
     }
   ] as (ModelIndexesOptions & { where?: WhereOptions })[]
 })
@@ -241,6 +250,18 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
   })
   Application: ApplicationModel
 
+  @ForeignKey(() => UserRegistrationModel)
+  @Column
+  userRegistrationId: number
+
+  @BelongsTo(() => UserRegistrationModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'cascade'
+  })
+  UserRegistration: UserRegistrationModel
+
   static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
     const where = { userId }
 
@@ -416,6 +437,10 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
       ? { latestVersion: this.Application.latestPeerTubeVersion }
       : undefined
 
+    const registration = this.UserRegistration
+      ? { id: this.UserRegistration.id, username: this.UserRegistration.username }
+      : undefined
+
     return {
       id: this.id,
       type: this.type,
@@ -429,6 +454,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
       actorFollow,
       plugin,
       peertube,
+      registration,
       createdAt: this.createdAt.toISOString(),
       updatedAt: this.updatedAt.toISOString()
     }
diff --git a/server/models/user/user-registration.ts b/server/models/user/user-registration.ts
new file mode 100644 (file)
index 0000000..adda3cc
--- /dev/null
@@ -0,0 +1,259 @@
+import { FindOptions, Op, WhereOptions } from 'sequelize'
+import {
+  AllowNull,
+  BeforeCreate,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType,
+  ForeignKey,
+  Is,
+  IsEmail,
+  Model,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import {
+  isRegistrationModerationResponseValid,
+  isRegistrationReasonValid,
+  isRegistrationStateValid
+} from '@server/helpers/custom-validators/user-registration'
+import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels'
+import { cryptPassword } from '@server/helpers/peertube-crypto'
+import { USER_REGISTRATION_STATES } from '@server/initializers/constants'
+import { MRegistration, MRegistrationFormattable } from '@server/types/models'
+import { UserRegistration, UserRegistrationState } from '@shared/models'
+import { AttributesOnly } from '@shared/typescript-utils'
+import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users'
+import { getSort, throwIfNotValid } from '../shared'
+import { UserModel } from './user'
+
+@Table({
+  tableName: 'userRegistration',
+  indexes: [
+    {
+      fields: [ 'username' ],
+      unique: true
+    },
+    {
+      fields: [ 'email' ],
+      unique: true
+    },
+    {
+      fields: [ 'channelHandle' ],
+      unique: true
+    },
+    {
+      fields: [ 'userId' ],
+      unique: true
+    }
+  ]
+})
+export class UserRegistrationModel extends Model<Partial<AttributesOnly<UserRegistrationModel>>> {
+
+  @AllowNull(false)
+  @Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state'))
+  @Column
+  state: UserRegistrationState
+
+  @AllowNull(false)
+  @Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason'))
+  @Column(DataType.TEXT)
+  registrationReason: string
+
+  @AllowNull(true)
+  @Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true))
+  @Column(DataType.TEXT)
+  moderationResponse: string
+
+  @AllowNull(true)
+  @Is('RegistrationPassword', value => throwIfNotValid(value, isUserPasswordValid, 'registration password', true))
+  @Column
+  password: string
+
+  @AllowNull(false)
+  @Column
+  username: string
+
+  @AllowNull(false)
+  @IsEmail
+  @Column(DataType.STRING(400))
+  email: string
+
+  @AllowNull(true)
+  @Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
+  @Column
+  emailVerified: boolean
+
+  @AllowNull(true)
+  @Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true))
+  @Column
+  accountDisplayName: string
+
+  @AllowNull(true)
+  @Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true))
+  @Column
+  channelHandle: string
+
+  @AllowNull(true)
+  @Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true))
+  @Column
+  channelDisplayName: string
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => UserModel)
+  @Column
+  userId: number
+
+  @BelongsTo(() => UserModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'SET NULL'
+  })
+  User: UserModel
+
+  @BeforeCreate
+  static async cryptPasswordIfNeeded (instance: UserRegistrationModel) {
+    instance.password = await cryptPassword(instance.password)
+  }
+
+  static load (id: number): Promise<MRegistration> {
+    return UserRegistrationModel.findByPk(id)
+  }
+
+  static loadByEmail (email: string): Promise<MRegistration> {
+    const query = {
+      where: { email }
+    }
+
+    return UserRegistrationModel.findOne(query)
+  }
+
+  static loadByEmailOrUsername (emailOrUsername: string): Promise<MRegistration> {
+    const query = {
+      where: {
+        [Op.or]: [
+          { email: emailOrUsername },
+          { username: emailOrUsername }
+        ]
+      }
+    }
+
+    return UserRegistrationModel.findOne(query)
+  }
+
+  static loadByEmailOrHandle (options: {
+    email: string
+    username: string
+    channelHandle?: string
+  }): Promise<MRegistration> {
+    const { email, username, channelHandle } = options
+
+    let or: WhereOptions = [
+      { email },
+      { channelHandle: username },
+      { username }
+    ]
+
+    if (channelHandle) {
+      or = or.concat([
+        { username: channelHandle },
+        { channelHandle }
+      ])
+    }
+
+    const query = {
+      where: {
+        [Op.or]: or
+      }
+    }
+
+    return UserRegistrationModel.findOne(query)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static listForApi (options: {
+    start: number
+    count: number
+    sort: string
+    search?: string
+  }) {
+    const { start, count, sort, search } = options
+
+    const where: WhereOptions = {}
+
+    if (search) {
+      Object.assign(where, {
+        [Op.or]: [
+          {
+            email: {
+              [Op.iLike]: '%' + search + '%'
+            }
+          },
+          {
+            username: {
+              [Op.iLike]: '%' + search + '%'
+            }
+          }
+        ]
+      })
+    }
+
+    const query: FindOptions = {
+      offset: start,
+      limit: count,
+      order: getSort(sort),
+      where,
+      include: [
+        {
+          model: UserModel.unscoped(),
+          required: false
+        }
+      ]
+    }
+
+    return Promise.all([
+      UserRegistrationModel.count(query),
+      UserRegistrationModel.findAll<MRegistrationFormattable>(query)
+    ]).then(([ total, data ]) => ({ total, data }))
+  }
+
+  // ---------------------------------------------------------------------------
+
+  toFormattedJSON (this: MRegistrationFormattable): UserRegistration {
+    return {
+      id: this.id,
+
+      state: {
+        id: this.state,
+        label: USER_REGISTRATION_STATES[this.state]
+      },
+
+      registrationReason: this.registrationReason,
+      moderationResponse: this.moderationResponse,
+
+      username: this.username,
+      email: this.email,
+      emailVerified: this.emailVerified,
+
+      accountDisplayName: this.accountDisplayName,
+
+      channelHandle: this.channelHandle,
+      channelDisplayName: this.channelDisplayName,
+
+      createdAt: this.createdAt,
+      updatedAt: this.updatedAt,
+
+      user: this.User
+        ? { id: this.User.id }
+        : null
+    }
+  }
+}
index 3fd808edc9fd3f5bdfa8d05acce0c03278a2e53c..bfc9b30495a55791034fce1768802307949f02fc 100644 (file)
@@ -30,6 +30,7 @@ import {
   MUserNotifSettingChannelDefault,
   MUserWithNotificationSetting
 } from '@server/types/models'
+import { forceNumber } from '@shared/core-utils'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
 import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models'
@@ -63,14 +64,13 @@ import { ActorModel } from '../actor/actor'
 import { ActorFollowModel } from '../actor/actor-follow'
 import { ActorImageModel } from '../actor/actor-image'
 import { OAuthTokenModel } from '../oauth/oauth-token'
-import { getAdminUsersSort, throwIfNotValid } from '../utils'
+import { getAdminUsersSort, throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { VideoChannelModel } from '../video/video-channel'
 import { VideoImportModel } from '../video/video-import'
 import { VideoLiveModel } from '../video/video-live'
 import { VideoPlaylistModel } from '../video/video-playlist'
 import { UserNotificationSettingModel } from './user-notification-setting'
-import { forceNumber } from '@shared/core-utils'
 
 enum ScopeNames {
   FOR_ME_API = 'FOR_ME_API',
@@ -441,16 +441,17 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
   })
   OAuthTokens: OAuthTokenModel[]
 
+  // Used if we already set an encrypted password in user model
+  skipPasswordEncryption = false
+
   @BeforeCreate
   @BeforeUpdate
-  static cryptPasswordIfNeeded (instance: UserModel) {
-    if (instance.changed('password') && instance.password) {
-      return cryptPassword(instance.password)
-        .then(hash => {
-          instance.password = hash
-          return undefined
-        })
-    }
+  static async cryptPasswordIfNeeded (instance: UserModel) {
+    if (instance.skipPasswordEncryption) return
+    if (!instance.changed('password')) return
+    if (!instance.password) return
+
+    instance.password = await cryptPassword(instance.password)
   }
 
   @AfterUpdate
diff --git a/server/models/utils.ts b/server/models/utils.ts
deleted file mode 100644 (file)
index 3476799..0000000
+++ /dev/null
@@ -1,317 +0,0 @@
-import { literal, Op, OrderItem, Sequelize } from 'sequelize'
-import validator from 'validator'
-import { forceNumber } from '@shared/core-utils'
-
-type SortType = { sortModel: string, sortValue: string }
-
-// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
-function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  let finalField: string | ReturnType<typeof Sequelize.col>
-
-  if (field.toLowerCase() === 'match') { // Search
-    finalField = Sequelize.col('similarity')
-  } else {
-    finalField = field
-  }
-
-  return [ [ finalField, direction ], lastSort ]
-}
-
-function getAdminUsersSort (value: string): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  let finalField: string | ReturnType<typeof Sequelize.col>
-
-  if (field === 'videoQuotaUsed') { // Users list
-    finalField = Sequelize.col('videoQuotaUsed')
-  } else {
-    finalField = field
-  }
-
-  const nullPolicy = direction === 'ASC'
-    ? 'NULLS FIRST'
-    : 'NULLS LAST'
-
-  // FIXME: typings
-  return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
-}
-
-function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  if (field.toLowerCase() === 'name') {
-    return [ [ 'displayName', direction ], lastSort ]
-  }
-
-  return getSort(value, lastSort)
-}
-
-function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  if (field === 'totalReplies') {
-    return [
-      [ Sequelize.literal('"totalReplies"'), direction ],
-      lastSort
-    ]
-  }
-
-  return getSort(value, lastSort)
-}
-
-function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  if (field.toLowerCase() === 'trending') { // Sort by aggregation
-    return [
-      [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
-
-      [ Sequelize.col('VideoModel.views'), direction ],
-
-      lastSort
-    ]
-  } else if (field === 'publishedAt') {
-    return [
-      [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
-
-      [ Sequelize.col('VideoModel.publishedAt'), direction ],
-
-      lastSort
-    ]
-  }
-
-  let finalField: string | ReturnType<typeof Sequelize.col>
-
-  // Alias
-  if (field.toLowerCase() === 'match') { // Search
-    finalField = Sequelize.col('similarity')
-  } else {
-    finalField = field
-  }
-
-  const firstSort: OrderItem = typeof finalField === 'string'
-    ? finalField.split('.').concat([ direction ]) as OrderItem
-    : [ finalField, direction ]
-
-  return [ firstSort, lastSort ]
-}
-
-function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const [ firstSort ] = getSort(value)
-
-  if (model) return [ [ literal(`"${model}.${firstSort[0]}" ${firstSort[1]}`) ], lastSort ] as OrderItem[]
-  return [ firstSort, lastSort ]
-}
-
-function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  if (field === 'redundancyAllowed') {
-    return [
-      [ 'ActorFollowing.Server.redundancyAllowed', direction ],
-      lastSort
-    ]
-  }
-
-  return getSort(value, lastSort)
-}
-
-function getChannelSyncSort (value: string): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-  if (field.toLowerCase() === 'videochannel') {
-    return [
-      [ literal('"VideoChannel.name"'), direction ]
-    ]
-  }
-  return [ [ field, direction ] ]
-}
-
-function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
-  if (!model.createdAt || !model.updatedAt) {
-    throw new Error('Miss createdAt & updatedAt attributes to model')
-  }
-
-  const now = Date.now()
-  const createdAtTime = model.createdAt.getTime()
-  const updatedAtTime = model.updatedAt.getTime()
-
-  return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
-}
-
-function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
-  if (nullable && (value === null || value === undefined)) return
-
-  if (validator(value) === false) {
-    throw new Error(`"${value}" is not a valid ${fieldName}.`)
-  }
-}
-
-function buildTrigramSearchIndex (indexName: string, attribute: string) {
-  return {
-    name: indexName,
-    // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
-    fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
-    using: 'gin',
-    operator: 'gin_trgm_ops'
-  }
-}
-
-function createSimilarityAttribute (col: string, value: string) {
-  return Sequelize.fn(
-    'similarity',
-
-    searchTrigramNormalizeCol(col),
-
-    searchTrigramNormalizeValue(value)
-  )
-}
-
-function buildBlockedAccountSQL (blockerIds: number[]) {
-  const blockerIdsString = blockerIds.join(', ')
-
-  return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
-    ' UNION ' +
-    'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
-    'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
-    'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
-}
-
-function buildBlockedAccountSQLOptimized (columnNameJoin: string, blockerIds: number[]) {
-  const blockerIdsString = blockerIds.join(', ')
-
-  return [
-    literal(
-      `NOT EXISTS (` +
-      `  SELECT 1 FROM "accountBlocklist" ` +
-      `  WHERE "targetAccountId" = ${columnNameJoin} ` +
-      `  AND "accountId" IN (${blockerIdsString})` +
-      `)`
-    ),
-
-    literal(
-      `NOT EXISTS (` +
-      `  SELECT 1 FROM "account" ` +
-      `  INNER JOIN "actor" ON account."actorId" = actor.id ` +
-      `  INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
-      `  WHERE "account"."id" = ${columnNameJoin} ` +
-      `  AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
-      `)`
-    )
-  ]
-}
-
-function buildServerIdsFollowedBy (actorId: any) {
-  const actorIdNumber = forceNumber(actorId)
-
-  return '(' +
-    'SELECT "actor"."serverId" FROM "actorFollow" ' +
-    'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
-    'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
-  ')'
-}
-
-function buildWhereIdOrUUID (id: number | string) {
-  return validator.isInt('' + id) ? { id } : { uuid: id }
-}
-
-function parseAggregateResult (result: any) {
-  if (!result) return 0
-
-  const total = forceNumber(result)
-  if (isNaN(total)) return 0
-
-  return total
-}
-
-function parseRowCountResult (result: any) {
-  if (result.length !== 0) return result[0].total
-
-  return 0
-}
-
-function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
-  return stringArr.map(t => {
-    return t === null
-      ? null
-      : sequelize.escape('' + t)
-  }).join(', ')
-}
-
-function buildLocalAccountIdsIn () {
-  return literal(
-    '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
-  )
-}
-
-function buildLocalActorIdsIn () {
-  return literal(
-    '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
-  )
-}
-
-function buildDirectionAndField (value: string) {
-  let field: string
-  let direction: 'ASC' | 'DESC'
-
-  if (value.substring(0, 1) === '-') {
-    direction = 'DESC'
-    field = value.substring(1)
-  } else {
-    direction = 'ASC'
-    field = value
-  }
-
-  return { direction, field }
-}
-
-function searchAttribute (sourceField?: string, targetField?: string) {
-  if (!sourceField) return {}
-
-  return {
-    [targetField]: {
-      // FIXME: ts error
-      [Op.iLike as any]: `%${sourceField}%`
-    }
-  }
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  buildBlockedAccountSQL,
-  buildBlockedAccountSQLOptimized,
-  buildLocalActorIdsIn,
-  getPlaylistSort,
-  SortType,
-  buildLocalAccountIdsIn,
-  getSort,
-  getCommentSort,
-  getAdminUsersSort,
-  getVideoSort,
-  getBlacklistSort,
-  getChannelSyncSort,
-  createSimilarityAttribute,
-  throwIfNotValid,
-  buildServerIdsFollowedBy,
-  buildTrigramSearchIndex,
-  buildWhereIdOrUUID,
-  isOutdated,
-  parseAggregateResult,
-  getInstanceFollowsSort,
-  buildDirectionAndField,
-  createSafeIn,
-  searchAttribute,
-  parseRowCountResult
-}
-
-// ---------------------------------------------------------------------------
-
-function searchTrigramNormalizeValue (value: string) {
-  return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
-}
-
-function searchTrigramNormalizeCol (col: string) {
-  return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
-}
index f285db477bf72e095f742433c82e7181ef6d5eb2..6f05dbdc8bf2008126d85d1d1b99418593daf604 100644 (file)
@@ -488,7 +488,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
 }
 
 function getCategoryLabel (id: number) {
-  return VIDEO_CATEGORIES[id] || 'Misc'
+  return VIDEO_CATEGORIES[id] || 'Unknown'
 }
 
 function 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 (file)
index 0000000..a7eed22
--- /dev/null
@@ -0,0 +1,400 @@
+import { Model, Sequelize, Transaction } from 'sequelize'
+import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
+import { ActorImageType, VideoPrivacy } from '@shared/models'
+import { createSafeIn, getSort, parseRowCountResult } from '../../../shared'
+import { VideoCommentTableAttributes } from './video-comment-table-attributes'
+
+export interface ListVideoCommentsOptions {
+  selectType: 'api' | 'feed' | 'comment-only'
+
+  start?: number
+  count?: number
+  sort?: string
+
+  videoId?: number
+  threadId?: number
+  accountId?: number
+  videoChannelId?: number
+
+  blockerAccountIds?: number[]
+
+  isThread?: boolean
+  notDeleted?: boolean
+  isLocal?: boolean
+  onLocalVideo?: boolean
+  onPublicVideo?: boolean
+  videoAccountOwnerId?: boolean
+
+  search?: string
+  searchAccount?: string
+  searchVideo?: string
+
+  includeReplyCounters?: boolean
+
+  transaction?: Transaction
+}
+
+export class VideoCommentListQueryBuilder extends AbstractRunQuery {
+  private readonly tableAttributes = new VideoCommentTableAttributes()
+
+  private innerQuery: string
+
+  private select = ''
+  private joins = ''
+
+  private innerSelect = ''
+  private innerJoins = ''
+  private innerLateralJoins = ''
+  private innerWhere = ''
+
+  private readonly built = {
+    cte: false,
+    accountJoin: false,
+    videoJoin: false,
+    videoChannelJoin: false,
+    avatarJoin: false
+  }
+
+  constructor (
+    protected readonly sequelize: Sequelize,
+    private readonly options: ListVideoCommentsOptions
+  ) {
+    super(sequelize)
+
+    if (this.options.includeReplyCounters && !this.options.videoId) {
+      throw new Error('Cannot include reply counters without videoId')
+    }
+  }
+
+  async listComments <T extends Model> () {
+    this.buildListQuery()
+
+    const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
+    const modelBuilder = new ModelBuilder<T>(this.sequelize)
+
+    return modelBuilder.createModels(results, 'VideoComment')
+  }
+
+  async countComments () {
+    this.buildCountQuery()
+
+    const result = await this.runQuery({ transaction: this.options.transaction })
+
+    return parseRowCountResult(result)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private buildListQuery () {
+    this.buildInnerListQuery()
+    this.buildListSelect()
+
+    this.query = `${this.select} ` +
+      `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
+      `${this.joins} ` +
+      `${this.getOrder()}`
+  }
+
+  private buildInnerListQuery () {
+    this.buildWhere()
+    this.buildInnerListSelect()
+
+    this.innerQuery = `${this.innerSelect} ` +
+      `FROM "videoComment" AS "VideoCommentModel" ` +
+      `${this.innerJoins} ` +
+      `${this.innerLateralJoins} ` +
+      `${this.innerWhere} ` +
+      `${this.getOrder()} ` +
+      `${this.getInnerLimit()}`
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private buildCountQuery () {
+    this.buildWhere()
+
+    this.query = `SELECT COUNT(*) AS "total" ` +
+      `FROM "videoComment" AS "VideoCommentModel" ` +
+      `${this.innerJoins} ` +
+      `${this.innerWhere}`
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private buildWhere () {
+    let where: string[] = []
+
+    if (this.options.videoId) {
+      this.replacements.videoId = this.options.videoId
+
+      where.push('"VideoCommentModel"."videoId" = :videoId')
+    }
+
+    if (this.options.threadId) {
+      this.replacements.threadId = this.options.threadId
+
+      where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
+    }
+
+    if (this.options.accountId) {
+      this.replacements.accountId = this.options.accountId
+
+      where.push('"VideoCommentModel"."accountId" = :accountId')
+    }
+
+    if (this.options.videoChannelId) {
+      this.buildVideoChannelJoin()
+
+      this.replacements.videoChannelId = this.options.videoChannelId
+
+      where.push('"Account->VideoChannel"."id" = :videoChannelId')
+    }
+
+    if (this.options.blockerAccountIds) {
+      this.buildVideoChannelJoin()
+
+      where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
+    }
+
+    if (this.options.isThread === true) {
+      where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
+    }
+
+    if (this.options.notDeleted === true) {
+      where.push('"VideoCommentModel"."deletedAt" IS NULL')
+    }
+
+    if (this.options.isLocal === true) {
+      this.buildAccountJoin()
+
+      where.push('"Account->Actor"."serverId" IS NULL')
+    } else if (this.options.isLocal === false) {
+      this.buildAccountJoin()
+
+      where.push('"Account->Actor"."serverId" IS NOT NULL')
+    }
+
+    if (this.options.onLocalVideo === true) {
+      this.buildVideoJoin()
+
+      where.push('"Video"."remote" IS FALSE')
+    } else if (this.options.onLocalVideo === false) {
+      this.buildVideoJoin()
+
+      where.push('"Video"."remote" IS TRUE')
+    }
+
+    if (this.options.onPublicVideo === true) {
+      this.buildVideoJoin()
+
+      where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
+    }
+
+    if (this.options.videoAccountOwnerId) {
+      this.buildVideoChannelJoin()
+
+      this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
+
+      where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
+    }
+
+    if (this.options.search) {
+      this.buildVideoJoin()
+      this.buildAccountJoin()
+
+      const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
+
+      where.push(
+        `(` +
+          `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
+          `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
+          `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
+          `"Video"."name" ILIKE ${escapedLikeSearch} ` +
+        `)`
+      )
+    }
+
+    if (this.options.searchAccount) {
+      this.buildAccountJoin()
+
+      const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
+
+      where.push(
+        `(` +
+          `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
+          `"Account"."name" ILIKE ${escapedLikeSearch} ` +
+        `)`
+      )
+    }
+
+    if (this.options.searchVideo) {
+      this.buildVideoJoin()
+
+      const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
+
+      where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
+    }
+
+    if (where.length !== 0) {
+      this.innerWhere = `WHERE ${where.join(' AND ')}`
+    }
+  }
+
+  private buildAccountJoin () {
+    if (this.built.accountJoin) return
+
+    this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
+      'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
+      'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
+
+    this.built.accountJoin = true
+  }
+
+  private buildVideoJoin () {
+    if (this.built.videoJoin) return
+
+    this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
+
+    this.built.videoJoin = true
+  }
+
+  private buildVideoChannelJoin () {
+    if (this.built.videoChannelJoin) return
+
+    this.buildVideoJoin()
+
+    this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
+
+    this.built.videoChannelJoin = true
+  }
+
+  private buildAvatarsJoin () {
+    if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
+    if (this.built.avatarJoin) return
+
+    this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
+      `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
+        `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
+
+    this.built.avatarJoin = true
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private buildListSelect () {
+    const toSelect = [ '"VideoCommentModel".*' ]
+
+    if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
+      this.buildAvatarsJoin()
+
+      toSelect.push(this.tableAttributes.getAvatarAttributes())
+    }
+
+    this.select = this.buildSelect(toSelect)
+  }
+
+  private buildInnerListSelect () {
+    let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
+
+    if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
+      this.buildAccountJoin()
+      this.buildVideoJoin()
+
+      toSelect = toSelect.concat([
+        this.tableAttributes.getVideoAttributes(),
+        this.tableAttributes.getAccountAttributes(),
+        this.tableAttributes.getActorAttributes(),
+        this.tableAttributes.getServerAttributes()
+      ])
+    }
+
+    if (this.options.includeReplyCounters === true) {
+      this.buildTotalRepliesSelect()
+      this.buildAuthorTotalRepliesSelect()
+
+      toSelect.push('"totalRepliesFromVideoAuthor"."count" AS "totalRepliesFromVideoAuthor"')
+      toSelect.push('"totalReplies"."count" AS "totalReplies"')
+    }
+
+    this.innerSelect = this.buildSelect(toSelect)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private getBlockWhere (commentTableName: string, channelTableName: string) {
+    const where: string[] = []
+
+    const blockerIdsString = createSafeIn(
+      this.sequelize,
+      this.options.blockerAccountIds,
+      [ `"${channelTableName}"."accountId"` ]
+    )
+
+    where.push(
+      `NOT EXISTS (` +
+        `SELECT 1 FROM "accountBlocklist" ` +
+        `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
+        `AND "accountId" IN (${blockerIdsString})` +
+      `)`
+    )
+
+    where.push(
+      `NOT EXISTS (` +
+        `SELECT 1 FROM "account" ` +
+        `INNER JOIN "actor" ON account."actorId" = actor.id ` +
+        `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
+        `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
+        `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
+      `)`
+    )
+
+    return where
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private buildTotalRepliesSelect () {
+    const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
+
+    // Help the planner by providing videoId that should filter out many comments
+    this.replacements.videoId = this.options.videoId
+
+    this.innerLateralJoins += `LEFT JOIN LATERAL (` +
+      `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
+      `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
+      `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
+      `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
+        `AND "deletedAt" IS NULL ` +
+        `AND ${blockWhereString} ` +
+    `) "totalReplies" ON TRUE `
+  }
+
+  private buildAuthorTotalRepliesSelect () {
+    // Help the planner by providing videoId that should filter out many comments
+    this.replacements.videoId = this.options.videoId
+
+    this.innerLateralJoins += `LEFT JOIN LATERAL (` +
+      `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
+      `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
+      `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
+      `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
+    `) "totalRepliesFromVideoAuthor" ON TRUE `
+  }
+
+  private getOrder () {
+    if (!this.options.sort) return ''
+
+    const orders = getSort(this.options.sort)
+
+    return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
+  }
+
+  private getInnerLimit () {
+    if (!this.options.count) return ''
+
+    this.replacements.limit = this.options.count
+    this.replacements.offset = this.options.start || 0
+
+    return `LIMIT :limit OFFSET :offset `
+  }
+}
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 (file)
index 0000000..87f8750
--- /dev/null
@@ -0,0 +1,43 @@
+import { Memoize } from '@server/helpers/memoize'
+import { AccountModel } from '@server/models/account/account'
+import { ActorModel } from '@server/models/actor/actor'
+import { ActorImageModel } from '@server/models/actor/actor-image'
+import { ServerModel } from '@server/models/server/server'
+import { VideoCommentModel } from '../../video-comment'
+
+export class VideoCommentTableAttributes {
+
+  @Memoize()
+  getVideoCommentAttributes () {
+    return VideoCommentModel.getSQLAttributes('VideoCommentModel').join(', ')
+  }
+
+  @Memoize()
+  getAccountAttributes () {
+    return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
+  }
+
+  @Memoize()
+  getVideoAttributes () {
+    return [
+      `"Video"."id" AS "Video.id"`,
+      `"Video"."uuid" AS "Video.uuid"`,
+      `"Video"."name" AS "Video.name"`
+    ].join(', ')
+  }
+
+  @Memoize()
+  getActorAttributes () {
+    return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
+  }
+
+  @Memoize()
+  getServerAttributes () {
+    return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
+  }
+
+  @Memoize()
+  getAvatarAttributes () {
+    return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
+  }
+}
index f0ce69501aab1a2fd911658006cc305d6d73b0d8..cbd57ad8c922e009ff3fee4b8f0c1bae14baa459 100644 (file)
@@ -1,9 +1,9 @@
 import { Sequelize } from 'sequelize'
 import validator from 'validator'
-import { createSafeIn } from '@server/models/utils'
 import { MUserAccountId } from '@server/types/models'
 import { ActorImageType } from '@shared/models'
 import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
+import { createSafeIn } from '../../../../shared'
 import { VideoTableAttributes } from './video-table-attributes'
 
 /**
index 7c864bf27978b0e9cf92782edb4bfdf2d1945201..62f1855c74d055aaec81b2d607b12551406473c3 100644 (file)
@@ -2,11 +2,12 @@ import { Sequelize, Transaction } from 'sequelize'
 import validator from 'validator'
 import { exists } from '@server/helpers/custom-validators/misc'
 import { WEBSERVER } from '@server/initializers/constants'
-import { buildDirectionAndField, createSafeIn, parseRowCountResult } from '@server/models/utils'
+import { buildSortDirectionAndField } from '@server/models/shared'
 import { MUserAccountId, MUserId } from '@server/types/models'
+import { forceNumber } from '@shared/core-utils'
 import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
+import { createSafeIn, parseRowCountResult } from '../../../shared'
 import { AbstractRunQuery } from '../../../shared/abstract-run-query'
-import { forceNumber } from '@shared/core-utils'
 
 /**
  *
@@ -665,7 +666,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
   }
 
   private buildOrder (value: string) {
-    const { direction, field } = buildDirectionAndField(value)
+    const { direction, field } = buildSortDirectionAndField(value)
     if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
 
     if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
index 653b9694b8a6adef8a3c68cfb8b1822e6c2667c3..cebde3755e27669e5d587be7061569ad07907c2b 100644 (file)
@@ -4,7 +4,7 @@ import { MTag } from '@server/types/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
 import { isVideoTagValid } from '../../helpers/custom-validators/videos'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 import { VideoTagModel } from './video-tag'
 
index 1cd8224c0a97a8b0dbf63b6a105f4cec2014c219..9247d0e2b1fd5fff6964382b44879768d2eb4755 100644 (file)
@@ -5,7 +5,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
 import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
 import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
 import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
-import { getBlacklistSort, searchAttribute, SortType, throwIfNotValid } from '../utils'
+import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared'
 import { ThumbnailModel } from './thumbnail'
 import { VideoModel } from './video'
 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
@@ -57,7 +57,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
   static listForApi (parameters: {
     start: number
     count: number
-    sort: SortType
+    sort: string
     search?: string
     type?: VideoBlacklistType
   }) {
@@ -67,7 +67,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
       return {
         offset: start,
         limit: count,
-        order: getBlacklistSort(sort.sortModel, sort.sortValue)
+        order: getBlacklistSort(sort)
       }
     }
 
index 5fbcd6e3b9f278512ff3091b1e97dad8d01c1c8e..2eaa77407e35740f99bc72c92402a90d7089f55e 100644 (file)
@@ -23,7 +23,7 @@ import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/vid
 import { logger } from '../../helpers/logger'
 import { CONFIG } from '../../initializers/config'
 import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
-import { buildWhereIdOrUUID, throwIfNotValid } from '../utils'
+import { buildWhereIdOrUUID, throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 
 export enum ScopeNames {
index 1a1b8c88de203e55dbff3e1f11f02ab7d351a069..2db4b523a654b6fda12274fed3f54dfe2325a7ff 100644 (file)
@@ -3,7 +3,7 @@ import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@se
 import { AttributesOnly } from '@shared/typescript-utils'
 import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
 import { AccountModel } from '../account/account'
-import { getSort } from '../utils'
+import { getSort } from '../shared'
 import { ScopeNames as VideoScopeNames, VideoModel } from './video'
 
 enum ScopeNames {
index 6e49cde107750029938bcdf190faf166e70f5bc4..a4cbf51f527f2412eae587615d191c52c598b115 100644 (file)
@@ -21,7 +21,7 @@ import { VideoChannelSync, VideoChannelSyncState } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { AccountModel } from '../account/account'
 import { UserModel } from '../user/user'
-import { getChannelSyncSort, throwIfNotValid } from '../utils'
+import { getChannelSyncSort, throwIfNotValid } from '../shared'
 import { VideoChannelModel } from './video-channel'
 
 @DefaultScope(() => ({
index 132c8f0211ea78d83dc04199e68eabb2f2f3b2d4..b71f5a1971602a100119d63ecc42533e6e06804b 100644 (file)
@@ -43,8 +43,14 @@ import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
 import { ActorFollowModel } from '../actor/actor-follow'
 import { ActorImageModel } from '../actor/actor-image'
 import { ServerModel } from '../server/server'
-import { setAsUpdated } from '../shared'
-import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
+import {
+  buildServerIdsFollowedBy,
+  buildTrigramSearchIndex,
+  createSimilarityAttribute,
+  getSort,
+  setAsUpdated,
+  throwIfNotValid
+} from '../shared'
 import { VideoModel } from './video'
 import { VideoPlaylistModel } from './video-playlist'
 
@@ -831,6 +837,6 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
   }
 
   setAsUpdated (transaction?: Transaction) {
-    return setAsUpdated('videoChannel', this.id, transaction)
+    return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
   }
 }
index af9614d30439eb5dcd63a9d86b337bdfa62def77..ff514280936bb50cc39291293e2358f1bfe2c68a 100644 (file)
@@ -1,4 +1,4 @@
-import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
+import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
 import {
   AllowNull,
   BelongsTo,
@@ -13,11 +13,9 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { exists } from '@server/helpers/custom-validators/misc'
 import { getServerActor } from '@server/models/application/application'
 import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
-import { uniqify } from '@shared/core-utils'
-import { VideoPrivacy } from '@shared/models'
+import { pick, uniqify } from '@shared/core-utils'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
@@ -41,61 +39,19 @@ import {
 } from '../../types/models/video'
 import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
 import { AccountModel } from '../account/account'
-import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
-import {
-  buildBlockedAccountSQL,
-  buildBlockedAccountSQLOptimized,
-  buildLocalAccountIdsIn,
-  getCommentSort,
-  searchAttribute,
-  throwIfNotValid
-} from '../utils'
+import { ActorModel } from '../actor/actor'
+import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared'
+import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
 import { VideoModel } from './video'
 import { VideoChannelModel } from './video-channel'
 
 export enum ScopeNames {
   WITH_ACCOUNT = 'WITH_ACCOUNT',
-  WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
   WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
-  WITH_VIDEO = 'WITH_VIDEO',
-  ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
+  WITH_VIDEO = 'WITH_VIDEO'
 }
 
 @Scopes(() => ({
-  [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
-    return {
-      attributes: {
-        include: [
-          [
-            Sequelize.literal(
-              '(' +
-                'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
-                'SELECT COUNT("replies"."id") ' +
-                'FROM "videoComment" AS "replies" ' +
-                'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
-                'AND "deletedAt" IS NULL ' +
-                'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
-              ')'
-            ),
-            'totalReplies'
-          ],
-          [
-            Sequelize.literal(
-              '(' +
-                'SELECT COUNT("replies"."id") ' +
-                'FROM "videoComment" AS "replies" ' +
-                'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
-                'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
-                'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
-                'AND "replies"."accountId" = "videoChannel"."accountId"' +
-              ')'
-            ),
-            'totalRepliesFromVideoAuthor'
-          ]
-        ]
-      }
-    } as FindOptions
-  },
   [ScopeNames.WITH_ACCOUNT]: {
     include: [
       {
@@ -103,22 +59,6 @@ export enum ScopeNames {
       }
     ]
   },
-  [ScopeNames.WITH_ACCOUNT_FOR_API]: {
-    include: [
-      {
-        model: AccountModel.unscoped(),
-        include: [
-          {
-            attributes: {
-              exclude: unusedActorAttributesForAPI
-            },
-            model: ActorModel, // Default scope includes avatar and server
-            required: true
-          }
-        ]
-      }
-    ]
-  },
   [ScopeNames.WITH_IN_REPLY_TO]: {
     include: [
       {
@@ -252,6 +192,18 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
   })
   CommentAbuses: VideoCommentAbuseModel[]
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static loadById (id: number, t?: Transaction): Promise<MComment> {
     const query: FindOptions = {
       where: {
@@ -319,93 +271,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
     searchAccount?: string
     searchVideo?: string
   }) {
-    const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters
+    const queryOptions: ListVideoCommentsOptions = {
+      ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
 
-    const where: WhereOptions = {
-      deletedAt: null
-    }
-
-    const whereAccount: WhereOptions = {}
-    const whereActor: WhereOptions = {}
-    const whereVideo: WhereOptions = {}
-
-    if (isLocal === true) {
-      Object.assign(whereActor, {
-        serverId: null
-      })
-    } else if (isLocal === false) {
-      Object.assign(whereActor, {
-        serverId: {
-          [Op.ne]: null
-        }
-      })
-    }
-
-    if (search) {
-      Object.assign(where, {
-        [Op.or]: [
-          searchAttribute(search, 'text'),
-          searchAttribute(search, '$Account.Actor.preferredUsername$'),
-          searchAttribute(search, '$Account.name$'),
-          searchAttribute(search, '$Video.name$')
-        ]
-      })
-    }
-
-    if (searchAccount) {
-      Object.assign(whereActor, {
-        [Op.or]: [
-          searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
-          searchAttribute(searchAccount, '$Account.name$')
-        ]
-      })
-    }
-
-    if (searchVideo) {
-      Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
-    }
-
-    if (exists(onLocalVideo)) {
-      Object.assign(whereVideo, { remote: !onLocalVideo })
-    }
-
-    const getQuery = (forCount: boolean) => {
-      return {
-        offset: start,
-        limit: count,
-        order: getCommentSort(sort),
-        where,
-        include: [
-          {
-            model: AccountModel.unscoped(),
-            required: true,
-            where: whereAccount,
-            include: [
-              {
-                attributes: {
-                  exclude: unusedActorAttributesForAPI
-                },
-                model: forCount === true
-                  ? ActorModel.unscoped() // Default scope includes avatar and server
-                  : ActorModel,
-                required: true,
-                where: whereActor
-              }
-            ]
-          },
-          {
-            model: VideoModel.unscoped(),
-            required: true,
-            where: whereVideo
-          }
-        ]
-      }
+      selectType: 'api',
+      notDeleted: true
     }
 
     return Promise.all([
-      VideoCommentModel.count(getQuery(true)),
-      VideoCommentModel.findAll(getQuery(false))
-    ]).then(([ total, data ]) => ({ total, data }))
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
+    ]).then(([ rows, count ]) => {
+      return { total: count, data: rows }
+    })
   }
 
   static async listThreadsForApi (parameters: {
@@ -416,67 +294,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
     sort: string
     user?: MUserAccountId
   }) {
-    const { videoId, isVideoOwned, start, count, sort, user } = parameters
+    const { videoId, user } = parameters
 
-    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
+    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
 
-    const accountBlockedWhere = {
-      accountId: {
-        [Op.notIn]: Sequelize.literal(
-          '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
-        )
-      }
+    const commonOptions: ListVideoCommentsOptions = {
+      selectType: 'api',
+      videoId,
+      blockerAccountIds
     }
 
-    const queryList = {
-      offset: start,
-      limit: count,
-      order: getCommentSort(sort),
-      where: {
-        [Op.and]: [
-          {
-            videoId
-          },
-          {
-            inReplyToCommentId: null
-          },
-          {
-            [Op.or]: [
-              accountBlockedWhere,
-              {
-                accountId: null
-              }
-            ]
-          }
-        ]
-      }
+    const listOptions: ListVideoCommentsOptions = {
+      ...commonOptions,
+      ...pick(parameters, [ 'sort', 'start', 'count' ]),
+
+      isThread: true,
+      includeReplyCounters: true
     }
 
-    const findScopesList: (string | ScopeOptions)[] = [
-      ScopeNames.WITH_ACCOUNT_FOR_API,
-      {
-        method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
-      }
-    ]
+    const countOptions: ListVideoCommentsOptions = {
+      ...commonOptions,
 
-    const countScopesList: ScopeOptions[] = [
-      {
-        method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
-      }
-    ]
+      isThread: true
+    }
 
-    const notDeletedQueryCount = {
-      where: {
-        videoId,
-        deletedAt: null,
-        ...accountBlockedWhere
-      }
+    const notDeletedCountOptions: ListVideoCommentsOptions = {
+      ...commonOptions,
+
+      notDeleted: true
     }
 
     return Promise.all([
-      VideoCommentModel.scope(findScopesList).findAll(queryList),
-      VideoCommentModel.scope(countScopesList).count(queryList),
-      VideoCommentModel.count(notDeletedQueryCount)
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
     ]).then(([ rows, count, totalNotDeletedComments ]) => {
       return { total: count, data: rows, totalNotDeletedComments }
     })
@@ -484,54 +335,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
 
   static async listThreadCommentsForApi (parameters: {
     videoId: number
-    isVideoOwned: boolean
     threadId: number
     user?: MUserAccountId
   }) {
-    const { videoId, threadId, user, isVideoOwned } = parameters
+    const { user } = parameters
 
-    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
+    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
 
-    const query = {
-      order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
-      where: {
-        videoId,
-        [Op.and]: [
-          {
-            [Op.or]: [
-              { id: threadId },
-              { originCommentId: threadId }
-            ]
-          },
-          {
-            [Op.or]: [
-              {
-                accountId: {
-                  [Op.notIn]: Sequelize.literal(
-                    '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
-                  )
-                }
-              },
-              {
-                accountId: null
-              }
-            ]
-          }
-        ]
-      }
-    }
+    const queryOptions: ListVideoCommentsOptions = {
+      ...pick(parameters, [ 'videoId', 'threadId' ]),
 
-    const scopes: any[] = [
-      ScopeNames.WITH_ACCOUNT_FOR_API,
-      {
-        method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
-      }
-    ]
+      selectType: 'api',
+      sort: 'createdAt',
+
+      blockerAccountIds,
+      includeReplyCounters: true
+    }
 
     return Promise.all([
-      VideoCommentModel.count(query),
-      VideoCommentModel.scope(scopes).findAll(query)
-    ]).then(([ total, data ]) => ({ total, data }))
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
+    ]).then(([ rows, count ]) => {
+      return { total: count, data: rows }
+    })
   }
 
   static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@@ -559,31 +385,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
       .findAll(query)
   }
 
-  static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) {
-    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({
+  static async listAndCountByVideoForAP (parameters: {
+    video: MVideoImmutable
+    start: number
+    count: number
+  }) {
+    const { video } = parameters
+
+    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
+
+    const queryOptions: ListVideoCommentsOptions = {
+      ...pick(parameters, [ 'start', 'count' ]),
+
+      selectType: 'comment-only',
       videoId: video.id,
-      isVideoOwned: video.isOwned()
-    })
+      sort: 'createdAt',
 
-    const query = {
-      order: [ [ 'createdAt', 'ASC' ] ] as Order,
-      offset: start,
-      limit: count,
-      where: {
-        videoId: video.id,
-        accountId: {
-          [Op.notIn]: Sequelize.literal(
-            '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
-          )
-        }
-      },
-      transaction: t
+      blockerAccountIds
     }
 
     return Promise.all([
-      VideoCommentModel.count(query),
-      VideoCommentModel.findAll<MComment>(query)
-    ]).then(([ total, data ]) => ({ total, data }))
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
+    ]).then(([ rows, count ]) => {
+      return { total: count, data: rows }
+    })
   }
 
   static async listForFeed (parameters: {
@@ -592,97 +418,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
     videoId?: number
     accountId?: number
     videoChannelId?: number
-  }): Promise<MCommentOwnerVideoFeed[]> {
-    const serverActor = await getServerActor()
-    const { start, count, videoId, accountId, videoChannelId } = parameters
-
-    const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
-      '"VideoCommentModel"."accountId"',
-      [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
-    )
+  }) {
+    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
 
-    if (accountId) {
-      whereAnd.push({
-        accountId
-      })
-    }
+    const queryOptions: ListVideoCommentsOptions = {
+      ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
 
-    const accountWhere = {
-      [Op.and]: whereAnd
-    }
+      selectType: 'feed',
 
-    const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined
+      sort: '-createdAt',
+      onPublicVideo: true,
+      notDeleted: true,
 
-    const query = {
-      order: [ [ 'createdAt', 'DESC' ] ] as Order,
-      offset: start,
-      limit: count,
-      where: {
-        deletedAt: null,
-        accountId: accountWhere
-      },
-      include: [
-        {
-          attributes: [ 'name', 'uuid' ],
-          model: VideoModel.unscoped(),
-          required: true,
-          where: {
-            privacy: VideoPrivacy.PUBLIC
-          },
-          include: [
-            {
-              attributes: [ 'accountId' ],
-              model: VideoChannelModel.unscoped(),
-              required: true,
-              where: videoChannelWhere
-            }
-          ]
-        }
-      ]
+      blockerAccountIds
     }
 
-    if (videoId) query.where['videoId'] = videoId
-
-    return VideoCommentModel
-      .scope([ ScopeNames.WITH_ACCOUNT ])
-      .findAll(query)
+    return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
   }
 
   static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
-    const accountWhere = filter.onVideosOfAccount
-      ? { id: filter.onVideosOfAccount.id }
-      : {}
+    const queryOptions: ListVideoCommentsOptions = {
+      selectType: 'comment-only',
 
-    const query = {
-      limit: 1000,
-      where: {
-        deletedAt: null,
-        accountId: ofAccount.id
-      },
-      include: [
-        {
-          model: VideoModel,
-          required: true,
-          include: [
-            {
-              model: VideoChannelModel,
-              required: true,
-              include: [
-                {
-                  model: AccountModel,
-                  required: true,
-                  where: accountWhere
-                }
-              ]
-            }
-          ]
-        }
-      ]
+      accountId: ofAccount.id,
+      videoAccountOwnerId: filter.onVideosOfAccount?.id,
+
+      notDeleted: true,
+      count: 5000
     }
 
-    return VideoCommentModel
-      .scope([ ScopeNames.WITH_ACCOUNT ])
-      .findAll(query)
+    return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
   }
 
   static async getStats () {
@@ -750,9 +515,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
   }
 
   isOwned () {
-    if (!this.Account) {
-      return false
-    }
+    if (!this.Account) return false
 
     return this.Account.isOwned()
   }
@@ -906,22 +669,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
   }
 
   private static async buildBlockerAccountIds (options: {
-    videoId: number
-    isVideoOwned: boolean
-    user?: MUserAccountId
-  }) {
-    const { videoId, user, isVideoOwned } = options
+    user: MUserAccountId
+  }): Promise<number[]> {
+    const { user } = options
 
     const serverActor = await getServerActor()
     const blockerAccountIds = [ serverActor.Account.id ]
 
     if (user) blockerAccountIds.push(user.Account.id)
 
-    if (isVideoOwned) {
-      const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
-      if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
-    }
-
     return blockerAccountIds
   }
 }
index 9c4e6d078afbd5d61057194ce95180330e99995c..07bc13de1a9938d29f161047318e29712cb07aef 100644 (file)
@@ -21,6 +21,7 @@ import {
 import validator from 'validator'
 import { logger } from '@server/helpers/logger'
 import { extractVideo } from '@server/helpers/video'
+import { CONFIG } from '@server/initializers/config'
 import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
 import {
   getHLSPrivateFileUrl,
@@ -50,11 +51,9 @@ import {
 } from '../../initializers/constants'
 import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
-import { doesExist } from '../shared'
-import { parseAggregateResult, throwIfNotValid } from '../utils'
+import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
-import { CONFIG } from '@server/initializers/config'
 
 export enum ScopeNames {
   WITH_VIDEO = 'WITH_VIDEO',
@@ -266,7 +265,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
   static doesInfohashExist (infoHash: string) {
     const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
 
-    return doesExist(query, { infoHash })
+    return doesExist(this.sequelize, query, { infoHash })
   }
 
   static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
@@ -282,14 +281,14 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
                   'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
                   'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1'
 
-    return doesExist(query, { filename })
+    return doesExist(this.sequelize, query, { filename })
   }
 
   static async doesOwnedWebTorrentVideoFileExist (filename: string) {
     const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
                   `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
 
-    return doesExist(query, { filename })
+    return doesExist(this.sequelize, query, { filename })
   }
 
   static loadByFilename (filename: string) {
@@ -439,7 +438,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
     if (!element) return videoFile.save({ transaction })
 
     for (const k of Object.keys(videoFile.toJSON())) {
-      element[k] = videoFile[k]
+      element.set(k, videoFile[k])
     }
 
     return element.save({ transaction })
index da6b92c7a1e6672dffbc3135ff713cba2c8a2d06..c040e0fda69661a5cf5c041284e338971049fde8 100644 (file)
@@ -22,7 +22,7 @@ import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../help
 import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
 import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
 import { UserModel } from '../user/user'
-import { getSort, searchAttribute, throwIfNotValid } from '../utils'
+import { getSort, searchAttribute, throwIfNotValid } from '../shared'
 import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
 import { VideoChannelSyncModel } from './video-channel-sync'
 
index 7181b559989399a88c83c1ce6488c762ae5efa7d..b832f9768c04a66c0160ac03c47cafc3f8a59e37 100644 (file)
@@ -31,7 +31,7 @@ import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { AccountModel } from '../account/account'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
 import { VideoPlaylistModel } from './video-playlist'
 
@@ -309,7 +309,23 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
     return VideoPlaylistElementModel.increment({ position: by }, query)
   }
 
-  getType (this: MVideoPlaylistElementFormattable, displayNSFW?: boolean, accountId?: number) {
+  toFormattedJSON (
+    this: MVideoPlaylistElementFormattable,
+    options: { accountId?: number } = {}
+  ): VideoPlaylistElement {
+    return {
+      id: this.id,
+      position: this.position,
+      startTimestamp: this.startTimestamp,
+      stopTimestamp: this.stopTimestamp,
+
+      type: this.getType(options.accountId),
+
+      video: this.getVideoElement(options.accountId)
+    }
+  }
+
+  getType (this: MVideoPlaylistElementFormattable, accountId?: number) {
     const video = this.Video
 
     if (!video) return VideoPlaylistElementType.DELETED
@@ -323,34 +339,17 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
     if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE
 
     if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
-    if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE
 
     return VideoPlaylistElementType.REGULAR
   }
 
-  getVideoElement (this: MVideoPlaylistElementFormattable, displayNSFW?: boolean, accountId?: number) {
+  getVideoElement (this: MVideoPlaylistElementFormattable, accountId?: number) {
     if (!this.Video) return null
-    if (this.getType(displayNSFW, accountId) !== VideoPlaylistElementType.REGULAR) return null
+    if (this.getType(accountId) !== VideoPlaylistElementType.REGULAR) return null
 
     return this.Video.toFormattedJSON()
   }
 
-  toFormattedJSON (
-    this: MVideoPlaylistElementFormattable,
-    options: { displayNSFW?: boolean, accountId?: number } = {}
-  ): VideoPlaylistElement {
-    return {
-      id: this.id,
-      position: this.position,
-      startTimestamp: this.startTimestamp,
-      stopTimestamp: this.stopTimestamp,
-
-      type: this.getType(options.displayNSFW, options.accountId),
-
-      video: this.getVideoElement(options.displayNSFW, options.accountId)
-    }
-  }
-
   toActivityPubObject (this: MVideoPlaylistElementAP): PlaylistElementObject {
     const base: PlaylistElementObject = {
       id: this.url,
index 8bbe54c49531ba91650c8d7ffbbaea75dd2e3e86..faf4bea789ac290dfcd19b62cdce7eb86eb20985 100644 (file)
@@ -21,12 +21,8 @@ import { activityPubCollectionPagination } from '@server/lib/activitypub/collect
 import { MAccountId, MChannelId } from '@server/types/models'
 import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
 import { buildUUID, uuidToShort } from '@shared/extra-utils'
+import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
-import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
-import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
-import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
-import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
-import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import {
   isVideoPlaylistDescriptionValid,
@@ -53,7 +49,6 @@ import {
 } from '../../types/models/video/video-playlist'
 import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
 import { ActorModel } from '../actor/actor'
-import { setAsUpdated } from '../shared'
 import {
   buildServerIdsFollowedBy,
   buildTrigramSearchIndex,
@@ -61,8 +56,9 @@ import {
   createSimilarityAttribute,
   getPlaylistSort,
   isOutdated,
+  setAsUpdated,
   throwIfNotValid
-} from '../utils'
+} from '../shared'
 import { ThumbnailModel } from './thumbnail'
 import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
 import { VideoPlaylistElementModel } from './video-playlist-element'
@@ -641,7 +637,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
   }
 
   setAsRefreshed () {
-    return setAsUpdated('videoPlaylist', this.id)
+    return setAsUpdated({ sequelize: this.sequelize, table: 'videoPlaylist', id: this.id })
   }
 
   setVideosLength (videosLength: number) {
index f2190037ee8de32c9905f2ff46965f7ca4f1b520..b4de2b20fedd3d78b3a7042e665541ad3e310825 100644 (file)
@@ -7,7 +7,7 @@ import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models'
 import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
 import { ActorModel } from '../actor/actor'
-import { buildLocalActorIdsIn, throwIfNotValid } from '../utils'
+import { buildLocalActorIdsIn, throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 
 enum ScopeNames {
index 0386edf2842070ed59624b7aac83eed2e80a5aa1..a85c79c9f1d96c64c2542926aa964954bcfba0b8 100644 (file)
@@ -37,8 +37,7 @@ import {
   WEBSERVER
 } from '../../initializers/constants'
 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
-import { doesExist } from '../shared'
-import { throwIfNotValid } from '../utils'
+import { doesExist, throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 
 @Table({
@@ -138,7 +137,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
   static doesInfohashExist (infoHash: string) {
     const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
 
-    return doesExist(query, { infoHash })
+    return doesExist(this.sequelize, query, { infoHash })
   }
 
   static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
@@ -237,7 +236,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
       `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
       `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
 
-    return doesExist(query, { videoUUID })
+    return doesExist(this.sequelize, query, { videoUUID })
   }
 
   assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
index 56cc45cfeda4d8a7b370cb669db023ae9b420230..1a10d2da229ec59d4d8484fc582cea90dbbc07e3 100644 (file)
@@ -32,7 +32,7 @@ import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFil
 import { VideoPathManager } from '@server/lib/video-path-manager'
 import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
 import { getServerActor } from '@server/models/application/application'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
 import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
 import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils'
 import {
@@ -103,10 +103,9 @@ import { VideoRedundancyModel } from '../redundancy/video-redundancy'
 import { ServerModel } from '../server/server'
 import { TrackerModel } from '../server/tracker'
 import { VideoTrackerModel } from '../server/video-tracker'
-import { setAsUpdated } from '../shared'
+import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
 import { UserModel } from '../user/user'
 import { UserVideoHistoryModel } from '../user/user-video-history'
-import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
 import { VideoViewModel } from '../view/video-view'
 import {
   videoFilesModelToFormattedJSON,
@@ -1871,7 +1870,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
   }
 
   setAsRefreshed (transaction?: Transaction) {
-    return setAsUpdated('video', this.id, transaction)
+    return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
   }
 
   // ---------------------------------------------------------------------------
index 9d0d89a5900b2353fa7d4ecff0f4820eb7de9187..274117e861fa5e2afccead3679b2e4d2fd10a7bf 100644 (file)
@@ -21,6 +21,10 @@ import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-se
   indexes: [
     {
       fields: [ 'videoId' ]
+    },
+    {
+      fields: [ 'url' ],
+      unique: true
     }
   ]
 })
index eb677912379ad27f2b97b9ad1c1c0b705fff89a6..1c1495022c5a5d0ce0cd9628ecd8db55295f7241 100644 (file)
@@ -148,7 +148,7 @@ describe('Test AP cleaner', function () {
   it('Should destroy server 3 internal shares and correctly clean them', async function () {
     this.timeout(20000)
 
-    const preCount = await servers[0].sql.getCount('videoShare')
+    const preCount = await servers[0].sql.getVideoShareCount()
     expect(preCount).to.equal(6)
 
     await servers[2].sql.deleteAll('videoShare')
@@ -156,7 +156,7 @@ describe('Test AP cleaner', function () {
     await waitJobs(servers)
 
     // Still 6 because we don't have remote shares on local videos
-    const postCount = await servers[0].sql.getCount('videoShare')
+    const postCount = await servers[0].sql.getVideoShareCount()
     expect(postCount).to.equal(6)
   })
 
@@ -185,7 +185,7 @@ describe('Test AP cleaner', function () {
     async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') {
       const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` +
         `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'`
-      const res = await servers[0].sql.selectQuery(query)
+      const res = await servers[0].sql.selectQuery<{ url: string }>(query)
 
       for (const rate of res) {
         const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`)
@@ -231,7 +231,7 @@ describe('Test AP cleaner', function () {
       const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` +
         `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'`
 
-      const res = await servers[0].sql.selectQuery(query)
+      const res = await servers[0].sql.selectQuery<{ url: string, videoUUID: string }>(query)
 
       for (const comment of res) {
         const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`)
index 3415625ca9b1b437f568d052ac4b8efbc9efb539..93a3f3eb9c3a18418e1b64f3050cc575bc830acc 100644 (file)
@@ -79,6 +79,7 @@ describe('Test config API validators', function () {
     signup: {
       enabled: false,
       limit: 5,
+      requiresApproval: false,
       requiresEmailVerification: false,
       minimumAge: 16
     },
@@ -313,6 +314,7 @@ describe('Test config API validators', function () {
         signup: {
           enabled: true,
           limit: 5,
+          requiresApproval: true,
           requiresEmailVerification: true
         }
       }
index 7968ef80235491da1f9b5469c38d5d1c97910d99..f0f8819b923d7c1b0023279c1c999cc96ecf6589 100644 (file)
@@ -2,7 +2,14 @@
 
 import { MockSmtpServer } from '@server/tests/shared'
 import { HttpStatusCode } from '@shared/models'
-import { cleanupTests, ContactFormCommand, createSingleServer, killallServers, PeerTubeServer } from '@shared/server-commands'
+import {
+  cleanupTests,
+  ConfigCommand,
+  ContactFormCommand,
+  createSingleServer,
+  killallServers,
+  PeerTubeServer
+} from '@shared/server-commands'
 
 describe('Test contact form API validators', function () {
   let server: PeerTubeServer
@@ -38,7 +45,7 @@ describe('Test contact form API validators', function () {
     await killallServers([ server ])
 
     // Contact form is disabled
-    await server.run({ smtp: { hostname: '127.0.0.1', port: emailPort }, contact_form: { enabled: false } })
+    await server.run({ ...ConfigCommand.getEmailOverrideConfig(emailPort), contact_form: { enabled: false } })
     await command.send({ ...defaultBody, expectedStatus: HttpStatusCode.CONFLICT_409 })
   })
 
@@ -48,7 +55,7 @@ describe('Test contact form API validators', function () {
     await killallServers([ server ])
 
     // Email & contact form enabled
-    await server.run({ smtp: { hostname: '127.0.0.1', port: emailPort } })
+    await server.run(ConfigCommand.getEmailOverrideConfig(emailPort))
 
     await command.send({ ...defaultBody, fromEmail: 'badEmail', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
     await command.send({ ...defaultBody, fromEmail: 'badEmail@', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
index 961093bb563191fc83f445342d5b2d38c875a622..ddbcb42f8d3db622bdd6cb508daed3940db4323e 100644 (file)
@@ -15,6 +15,7 @@ import './metrics'
 import './my-user'
 import './plugins'
 import './redundancy'
+import './registrations'
 import './search'
 import './services'
 import './transcoding'
@@ -23,7 +24,7 @@ import './upload-quota'
 import './user-notifications'
 import './user-subscriptions'
 import './users-admin'
-import './users'
+import './users-emails'
 import './video-blacklist'
 import './video-captions'
 import './video-channel-syncs'
index 908407b9a5a898a1e5ce823072373f467037eb29..73dfd489d49d2141c3b79a0280fd5682ec993505 100644 (file)
@@ -24,7 +24,7 @@ describe('Test server redundancy API validators', function () {
   // ---------------------------------------------------------------
 
   before(async function () {
-    this.timeout(80000)
+    this.timeout(160000)
 
     servers = await createMultipleServers(2)
 
diff --git a/server/tests/api/check-params/registrations.ts b/server/tests/api/check-params/registrations.ts
new file mode 100644 (file)
index 0000000..fe16ebd
--- /dev/null
@@ -0,0 +1,433 @@
+import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
+import { omit } from '@shared/core-utils'
+import { HttpStatusCode, UserRole } from '@shared/models'
+import {
+  cleanupTests,
+  createSingleServer,
+  makePostBodyRequest,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  setDefaultAccountAvatar,
+  setDefaultChannelAvatar
+} from '@shared/server-commands'
+
+describe('Test registrations API validators', function () {
+  let server: PeerTubeServer
+  let userToken: string
+  let moderatorToken: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await createSingleServer(1)
+
+    await setAccessTokensToServers([ server ])
+    await setDefaultAccountAvatar([ server ])
+    await setDefaultChannelAvatar([ server ])
+
+    await server.config.enableSignup(false);
+
+    ({ token: moderatorToken } = await server.users.generate('moderator', UserRole.MODERATOR));
+    ({ token: userToken } = await server.users.generate('user', UserRole.USER))
+  })
+
+  describe('Register', function () {
+    const registrationPath = '/api/v1/users/register'
+    const registrationRequestPath = '/api/v1/users/registrations/request'
+
+    const baseCorrectParams = {
+      username: 'user3',
+      displayName: 'super user',
+      email: 'test3@example.com',
+      password: 'my super password',
+      registrationReason: 'my super registration reason'
+    }
+
+    describe('When registering a new user or requesting user registration', function () {
+
+      async function check (fields: any, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
+        await server.config.enableSignup(false)
+        await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus })
+
+        await server.config.enableSignup(true)
+        await makePostBodyRequest({ url: server.url, path: registrationRequestPath, fields, expectedStatus })
+      }
+
+      it('Should fail with a too small username', async function () {
+        const fields = { ...baseCorrectParams, username: '' }
+
+        await check(fields)
+      })
+
+      it('Should fail with a too long username', async function () {
+        const fields = { ...baseCorrectParams, username: 'super'.repeat(50) }
+
+        await check(fields)
+      })
+
+      it('Should fail with an incorrect username', async function () {
+        const fields = { ...baseCorrectParams, username: 'my username' }
+
+        await check(fields)
+      })
+
+      it('Should fail with a missing email', async function () {
+        const fields = omit(baseCorrectParams, [ 'email' ])
+
+        await check(fields)
+      })
+
+      it('Should fail with an invalid email', async function () {
+        const fields = { ...baseCorrectParams, email: 'test_example.com' }
+
+        await check(fields)
+      })
+
+      it('Should fail with a too small password', async function () {
+        const fields = { ...baseCorrectParams, password: 'bla' }
+
+        await check(fields)
+      })
+
+      it('Should fail with a too long password', async function () {
+        const fields = { ...baseCorrectParams, password: 'super'.repeat(61) }
+
+        await check(fields)
+      })
+
+      it('Should fail if we register a user with the same username', async function () {
+        const fields = { ...baseCorrectParams, username: 'root' }
+
+        await check(fields, HttpStatusCode.CONFLICT_409)
+      })
+
+      it('Should fail with a "peertube" username', async function () {
+        const fields = { ...baseCorrectParams, username: 'peertube' }
+
+        await check(fields, HttpStatusCode.CONFLICT_409)
+      })
+
+      it('Should fail if we register a user with the same email', async function () {
+        const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' }
+
+        await check(fields, HttpStatusCode.CONFLICT_409)
+      })
+
+      it('Should fail with a bad display name', async function () {
+        const fields = { ...baseCorrectParams, displayName: 'a'.repeat(150) }
+
+        await check(fields)
+      })
+
+      it('Should fail with a bad channel name', async function () {
+        const fields = { ...baseCorrectParams, channel: { name: '[]azf', displayName: 'toto' } }
+
+        await check(fields)
+      })
+
+      it('Should fail with a bad channel display name', async function () {
+        const fields = { ...baseCorrectParams, channel: { name: 'toto', displayName: '' } }
+
+        await check(fields)
+      })
+
+      it('Should fail with a channel name that is the same as username', async function () {
+        const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } }
+        const fields = { ...baseCorrectParams, ...source }
+
+        await check(fields)
+      })
+
+      it('Should fail with an existing channel', async function () {
+        const attributes = { name: 'existing_channel', displayName: 'hello', description: 'super description' }
+        await server.channels.create({ attributes })
+
+        const fields = { ...baseCorrectParams, channel: { name: 'existing_channel', displayName: 'toto' } }
+
+        await check(fields, HttpStatusCode.CONFLICT_409)
+      })
+
+      it('Should fail on a server with registration disabled', async function () {
+        this.timeout(60000)
+
+        await server.config.updateExistingSubConfig({
+          newConfig: {
+            signup: {
+              enabled: false
+            }
+          }
+        })
+
+        await server.registrations.register({ username: 'user4', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+        await server.registrations.requestRegistration({
+          username: 'user4',
+          registrationReason: 'reason',
+          expectedStatus: HttpStatusCode.FORBIDDEN_403
+        })
+      })
+
+      it('Should fail if the user limit is reached', async function () {
+        this.timeout(60000)
+
+        const { total } = await server.users.list()
+
+        await server.config.enableSignup(false, total)
+        await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+
+        await server.config.enableSignup(true, total)
+        await server.registrations.requestRegistration({
+          username: 'user42',
+          registrationReason: 'reason',
+          expectedStatus: HttpStatusCode.FORBIDDEN_403
+        })
+      })
+
+      it('Should succeed if the user limit is not reached', async function () {
+        this.timeout(60000)
+
+        const { total } = await server.users.list()
+
+        await server.config.enableSignup(false, total + 1)
+        await server.registrations.register({ username: 'user43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
+
+        await server.config.enableSignup(true, total + 2)
+        await server.registrations.requestRegistration({
+          username: 'user44',
+          registrationReason: 'reason',
+          expectedStatus: HttpStatusCode.OK_200
+        })
+      })
+    })
+
+    describe('On direct registration', function () {
+
+      it('Should succeed with the correct params', async function () {
+        await server.config.enableSignup(false)
+
+        const fields = {
+          username: 'user_direct_1',
+          displayName: 'super user direct 1',
+          email: 'user_direct_1@example.com',
+          password: 'my super password',
+          channel: { name: 'super_user_direct_1_channel', displayName: 'super user direct 1 channel' }
+        }
+
+        await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus: HttpStatusCode.NO_CONTENT_204 })
+      })
+
+      it('Should fail if the instance requires approval', async function () {
+        this.timeout(60000)
+
+        await server.config.enableSignup(true)
+        await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+      })
+    })
+
+    describe('On registration request', function () {
+
+      before(async function () {
+        this.timeout(60000)
+
+        await server.config.enableSignup(true)
+      })
+
+      it('Should fail with an invalid registration reason', async function () {
+        for (const registrationReason of [ '', 't', 't'.repeat(5000) ]) {
+          await server.registrations.requestRegistration({
+            username: 'user_request_1',
+            registrationReason,
+            expectedStatus: HttpStatusCode.BAD_REQUEST_400
+          })
+        }
+      })
+
+      it('Should succeed with the correct params', async function () {
+        await server.registrations.requestRegistration({
+          username: 'user_request_2',
+          registrationReason: 'tt',
+          channel: {
+            displayName: 'my user request 2 channel',
+            name: 'user_request_2_channel'
+          }
+        })
+      })
+
+      it('Should fail if the user is already awaiting registration approval', async function () {
+        await server.registrations.requestRegistration({
+          username: 'user_request_2',
+          registrationReason: 'tt',
+          channel: {
+            displayName: 'my user request 42 channel',
+            name: 'user_request_42_channel'
+          },
+          expectedStatus: HttpStatusCode.CONFLICT_409
+        })
+      })
+
+      it('Should fail if the channel is already awaiting registration approval', async function () {
+        await server.registrations.requestRegistration({
+          username: 'user42',
+          registrationReason: 'tt',
+          channel: {
+            displayName: 'my user request 2 channel',
+            name: 'user_request_2_channel'
+          },
+          expectedStatus: HttpStatusCode.CONFLICT_409
+        })
+      })
+
+      it('Should fail if the instance does not require approval', async function () {
+        this.timeout(60000)
+
+        await server.config.enableSignup(false)
+
+        await server.registrations.requestRegistration({
+          username: 'user42',
+          registrationReason: 'toto',
+          expectedStatus: HttpStatusCode.BAD_REQUEST_400
+        })
+      })
+    })
+  })
+
+  describe('Registrations accept/reject', function () {
+    let id1: number
+    let id2: number
+
+    before(async function () {
+      this.timeout(60000)
+
+      await server.config.enableSignup(true);
+
+      ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_2', registrationReason: 'toto' }));
+      ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_3', registrationReason: 'toto' }))
+    })
+
+    it('Should fail to accept/reject registration without token', async function () {
+      const options = { id: id1, moderationResponse: 'tt', token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }
+      await server.registrations.accept(options)
+      await server.registrations.reject(options)
+    })
+
+    it('Should fail to accept/reject registration with a non moderator user', async function () {
+      const options = { id: id1, moderationResponse: 'tt', token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }
+      await server.registrations.accept(options)
+      await server.registrations.reject(options)
+    })
+
+    it('Should fail to accept/reject registration with a bad registration id', async function () {
+      {
+        const options = { id: 't' as any, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
+        await server.registrations.accept(options)
+        await server.registrations.reject(options)
+      }
+
+      {
+        const options = { id: 42, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
+        await server.registrations.accept(options)
+        await server.registrations.reject(options)
+      }
+    })
+
+    it('Should fail to accept/reject registration with a bad moderation resposne', async function () {
+      for (const moderationResponse of [ '', 't', 't'.repeat(5000) ]) {
+        const options = { id: id1, moderationResponse, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
+        await server.registrations.accept(options)
+        await server.registrations.reject(options)
+      }
+    })
+
+    it('Should succeed to accept a registration', async function () {
+      await server.registrations.accept({ id: id1, moderationResponse: 'tt', token: moderatorToken })
+    })
+
+    it('Should succeed to reject a registration', async function () {
+      await server.registrations.reject({ id: id2, moderationResponse: 'tt', token: moderatorToken })
+    })
+
+    it('Should fail to accept/reject a registration that was already accepted/rejected', async function () {
+      for (const id of [ id1, id2 ]) {
+        const options = { id, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.CONFLICT_409 }
+        await server.registrations.accept(options)
+        await server.registrations.reject(options)
+      }
+    })
+  })
+
+  describe('Registrations deletion', function () {
+    let id1: number
+    let id2: number
+    let id3: number
+
+    before(async function () {
+      ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_4', registrationReason: 'toto' }));
+      ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_5', registrationReason: 'toto' }));
+      ({ id: id3 } = await server.registrations.requestRegistration({ username: 'request_6', registrationReason: 'toto' }))
+
+      await server.registrations.accept({ id: id2, moderationResponse: 'tt' })
+      await server.registrations.reject({ id: id3, moderationResponse: 'tt' })
+    })
+
+    it('Should fail to delete registration without token', async function () {
+      await server.registrations.delete({ id: id1, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should fail to delete registration with a non moderator user', async function () {
+      await server.registrations.delete({ id: id1, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should fail to delete registration with a bad registration id', async function () {
+      await server.registrations.delete({ id: 't' as any, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+      await server.registrations.delete({ id: 42, token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await server.registrations.delete({ id: id1, token: moderatorToken })
+      await server.registrations.delete({ id: id2, token: moderatorToken })
+      await server.registrations.delete({ id: id3, token: moderatorToken })
+    })
+  })
+
+  describe('Listing registrations', function () {
+    const path = '/api/v1/users/registrations'
+
+    it('Should fail with a bad start pagination', async function () {
+      await checkBadStartPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with a bad count pagination', async function () {
+      await checkBadCountPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with an incorrect sort', async function () {
+      await checkBadSortPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with a non authenticated user', async function () {
+      await server.registrations.list({
+        token: null,
+        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should fail with a non admin user', async function () {
+      await server.registrations.list({
+        token: userToken,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await server.registrations.list({
+        token: moderatorToken,
+        search: 'toto'
+      })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index 70e6f4af9322548925ada4b350fa717530e2a9a3..fdc711bd52420b46f2f387e48e7801d09d59f0e1 100644 (file)
@@ -42,7 +42,7 @@ describe('Test upload quota', function () {
       this.timeout(30000)
 
       const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
-      await server.users.register(user)
+      await server.registrations.register(user)
       const userToken = await server.login.getAccessToken(user)
 
       const attributes = { fixture: 'video_short2.webm' }
@@ -57,7 +57,7 @@ describe('Test upload quota', function () {
       this.timeout(30000)
 
       const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
-      await server.users.register(user)
+      await server.registrations.register(user)
       const userToken = await server.login.getAccessToken(user)
 
       const attributes = { fixture: 'video_short2.webm' }
index 7ba709c4a57601e3b90edc4e27f4cc29df4b8318..be2496bb47be80b9b9394289ee82977e238e6bd6 100644 (file)
@@ -5,6 +5,7 @@ import { omit } from '@shared/core-utils'
 import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
 import {
   cleanupTests,
+  ConfigCommand,
   createSingleServer,
   killallServers,
   makeGetRequest,
@@ -156,13 +157,7 @@ describe('Test users admin API validators', function () {
 
       await killallServers([ server ])
 
-      const config = {
-        smtp: {
-          hostname: '127.0.0.1',
-          port: emailPort
-        }
-      }
-      await server.run(config)
+      await server.run(ConfigCommand.getEmailOverrideConfig(emailPort))
 
       const fields = {
         ...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 (file)
index 0000000..8cfb1d1
--- /dev/null
@@ -0,0 +1,119 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+import { MockSmtpServer } from '@server/tests/shared'
+import { HttpStatusCode, UserRole } from '@shared/models'
+import { cleanupTests, createSingleServer, makePostBodyRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
+
+describe('Test users API validators', function () {
+  let server: PeerTubeServer
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await createSingleServer(1, {
+      rates_limit: {
+        ask_send_email: {
+          max: 10
+        }
+      }
+    })
+
+    await setAccessTokensToServers([ server ])
+    await server.config.enableSignup(true)
+
+    await server.users.generate('moderator2', UserRole.MODERATOR)
+
+    await server.registrations.requestRegistration({
+      username: 'request1',
+      registrationReason: 'tt'
+    })
+  })
+
+  describe('When asking a password reset', function () {
+    const path = '/api/v1/users/ask-reset-password'
+
+    it('Should fail with a missing email', async function () {
+      const fields = {}
+
+      await makePostBodyRequest({ url: server.url, path, fields })
+    })
+
+    it('Should fail with an invalid email', async function () {
+      const fields = { email: 'hello' }
+
+      await makePostBodyRequest({ url: server.url, path, fields })
+    })
+
+    it('Should success with the correct params', async function () {
+      const fields = { email: 'admin@example.com' }
+
+      await makePostBodyRequest({
+        url: server.url,
+        path,
+        fields,
+        expectedStatus: HttpStatusCode.NO_CONTENT_204
+      })
+    })
+  })
+
+  describe('When asking for an account verification email', function () {
+    const path = '/api/v1/users/ask-send-verify-email'
+
+    it('Should fail with a missing email', async function () {
+      const fields = {}
+
+      await makePostBodyRequest({ url: server.url, path, fields })
+    })
+
+    it('Should fail with an invalid email', async function () {
+      const fields = { email: 'hello' }
+
+      await makePostBodyRequest({ url: server.url, path, fields })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      const fields = { email: 'admin@example.com' }
+
+      await makePostBodyRequest({
+        url: server.url,
+        path,
+        fields,
+        expectedStatus: HttpStatusCode.NO_CONTENT_204
+      })
+    })
+  })
+
+  describe('When asking for a registration verification email', function () {
+    const path = '/api/v1/users/registrations/ask-send-verify-email'
+
+    it('Should fail with a missing email', async function () {
+      const fields = {}
+
+      await makePostBodyRequest({ url: server.url, path, fields })
+    })
+
+    it('Should fail with an invalid email', async function () {
+      const fields = { email: 'hello' }
+
+      await makePostBodyRequest({ url: server.url, path, fields })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      const fields = { email: 'request1@example.com' }
+
+      await makePostBodyRequest({
+        url: server.url,
+        path,
+        fields,
+        expectedStatus: HttpStatusCode.NO_CONTENT_204
+      })
+    })
+  })
+
+  after(async function () {
+    MockSmtpServer.Instance.kill()
+
+    await cleanupTests([ server ])
+  })
+})
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
deleted file mode 100644 (file)
index 7acfd8c..0000000
+++ /dev/null
@@ -1,255 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
-import { MockSmtpServer } from '@server/tests/shared'
-import { omit } from '@shared/core-utils'
-import { HttpStatusCode, UserRole } from '@shared/models'
-import { cleanupTests, createSingleServer, makePostBodyRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
-
-describe('Test users API validators', function () {
-  const path = '/api/v1/users/'
-  let server: PeerTubeServer
-  let serverWithRegistrationDisabled: PeerTubeServer
-
-  // ---------------------------------------------------------------
-
-  before(async function () {
-    this.timeout(30000)
-
-    const res = await Promise.all([
-      createSingleServer(1, { signup: { limit: 3 } }),
-      createSingleServer(2)
-    ])
-
-    server = res[0]
-    serverWithRegistrationDisabled = res[1]
-
-    await setAccessTokensToServers([ server ])
-
-    await server.users.generate('moderator2', UserRole.MODERATOR)
-  })
-
-  describe('When registering a new user', function () {
-    const registrationPath = path + '/register'
-    const baseCorrectParams = {
-      username: 'user3',
-      displayName: 'super user',
-      email: 'test3@example.com',
-      password: 'my super password'
-    }
-
-    it('Should fail with a too small username', async function () {
-      const fields = { ...baseCorrectParams, username: '' }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with a too long username', async function () {
-      const fields = { ...baseCorrectParams, username: 'super'.repeat(50) }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with an incorrect username', async function () {
-      const fields = { ...baseCorrectParams, username: 'my username' }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with a missing email', async function () {
-      const fields = omit(baseCorrectParams, [ 'email' ])
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with an invalid email', async function () {
-      const fields = { ...baseCorrectParams, email: 'test_example.com' }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with a too small password', async function () {
-      const fields = { ...baseCorrectParams, password: 'bla' }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with a too long password', async function () {
-      const fields = { ...baseCorrectParams, password: 'super'.repeat(61) }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail if we register a user with the same username', async function () {
-      const fields = { ...baseCorrectParams, username: 'root' }
-
-      await makePostBodyRequest({
-        url: server.url,
-        path: registrationPath,
-        token: server.accessToken,
-        fields,
-        expectedStatus: HttpStatusCode.CONFLICT_409
-      })
-    })
-
-    it('Should fail with a "peertube" username', async function () {
-      const fields = { ...baseCorrectParams, username: 'peertube' }
-
-      await makePostBodyRequest({
-        url: server.url,
-        path: registrationPath,
-        token: server.accessToken,
-        fields,
-        expectedStatus: HttpStatusCode.CONFLICT_409
-      })
-    })
-
-    it('Should fail if we register a user with the same email', async function () {
-      const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' }
-
-      await makePostBodyRequest({
-        url: server.url,
-        path: registrationPath,
-        token: server.accessToken,
-        fields,
-        expectedStatus: HttpStatusCode.CONFLICT_409
-      })
-    })
-
-    it('Should fail with a bad display name', async function () {
-      const fields = { ...baseCorrectParams, displayName: 'a'.repeat(150) }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with a bad channel name', async function () {
-      const fields = { ...baseCorrectParams, channel: { name: '[]azf', displayName: 'toto' } }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with a bad channel display name', async function () {
-      const fields = { ...baseCorrectParams, channel: { name: 'toto', displayName: '' } }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with a channel name that is the same as username', async function () {
-      const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } }
-      const fields = { ...baseCorrectParams, ...source }
-
-      await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
-    })
-
-    it('Should fail with an existing channel', async function () {
-      const attributes = { name: 'existing_channel', displayName: 'hello', description: 'super description' }
-      await server.channels.create({ attributes })
-
-      const fields = { ...baseCorrectParams, channel: { name: 'existing_channel', displayName: 'toto' } }
-
-      await makePostBodyRequest({
-        url: server.url,
-        path: registrationPath,
-        token: server.accessToken,
-        fields,
-        expectedStatus: HttpStatusCode.CONFLICT_409
-      })
-    })
-
-    it('Should succeed with the correct params', async function () {
-      const fields = { ...baseCorrectParams, channel: { name: 'super_channel', displayName: 'toto' } }
-
-      await makePostBodyRequest({
-        url: server.url,
-        path: registrationPath,
-        token: server.accessToken,
-        fields,
-        expectedStatus: HttpStatusCode.NO_CONTENT_204
-      })
-    })
-
-    it('Should fail on a server with registration disabled', async function () {
-      const fields = {
-        username: 'user4',
-        email: 'test4@example.com',
-        password: 'my super password 4'
-      }
-
-      await makePostBodyRequest({
-        url: serverWithRegistrationDisabled.url,
-        path: registrationPath,
-        token: serverWithRegistrationDisabled.accessToken,
-        fields,
-        expectedStatus: HttpStatusCode.FORBIDDEN_403
-      })
-    })
-  })
-
-  describe('When registering multiple users on a server with users limit', function () {
-
-    it('Should fail when after 3 registrations', async function () {
-      await server.users.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
-    })
-
-  })
-
-  describe('When asking a password reset', function () {
-    const path = '/api/v1/users/ask-reset-password'
-
-    it('Should fail with a missing email', async function () {
-      const fields = {}
-
-      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
-    })
-
-    it('Should fail with an invalid email', async function () {
-      const fields = { email: 'hello' }
-
-      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
-    })
-
-    it('Should success with the correct params', async function () {
-      const fields = { email: 'admin@example.com' }
-
-      await makePostBodyRequest({
-        url: server.url,
-        path,
-        token: server.accessToken,
-        fields,
-        expectedStatus: HttpStatusCode.NO_CONTENT_204
-      })
-    })
-  })
-
-  describe('When asking for an account verification email', function () {
-    const path = '/api/v1/users/ask-send-verify-email'
-
-    it('Should fail with a missing email', async function () {
-      const fields = {}
-
-      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
-    })
-
-    it('Should fail with an invalid email', async function () {
-      const fields = { email: 'hello' }
-
-      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
-    })
-
-    it('Should succeed with the correct params', async function () {
-      const fields = { email: 'admin@example.com' }
-
-      await makePostBodyRequest({
-        url: server.url,
-        path,
-        token: server.accessToken,
-        fields,
-        expectedStatus: HttpStatusCode.NO_CONTENT_204
-      })
-    })
-  })
-
-  after(async function () {
-    MockSmtpServer.Instance.kill()
-
-    await cleanupTests([ server, serverWithRegistrationDisabled ])
-  })
-})
index c0bb8d529f28f41a3a6b0a085d92f45abf9dc80a..f6959b83cf6519c43794c7d9956afcfd1191acaf 100644 (file)
@@ -78,9 +78,15 @@ describe('Fast restream in live', function () {
       const video = await server.videos.get({ id: liveId })
       expect(video.streamingPlaylists).to.have.lengthOf(1)
 
-      await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
-      await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
-      await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
+      try {
+        await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
+        await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
+        await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
+      } catch (err) {
+        // FIXME: try to debug error in CI "Unexpected end of JSON input"
+        console.error(err)
+        throw err
+      }
 
       await wait(100)
     }
@@ -129,7 +135,7 @@ describe('Fast restream in live', function () {
     await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
   })
 
-  it('Should correctly fast reastream in a permanent live with and without save replay', async function () {
+  it('Should correctly fast restream in a permanent live with and without save replay', async function () {
     this.timeout(480000)
 
     // A test can take a long time, so prefer to run them in parallel
index 8caa30a3d2c9304603c46aa6fc0c937e6f4a12c0..c0216b74fd3a6e8b03c130e5aa4c77ed5b0f9013 100644 (file)
@@ -2,4 +2,5 @@ import './admin-notifications'
 import './comments-notifications'
 import './moderation-notifications'
 import './notifications-api'
+import './registrations-notifications'
 import './user-notifications'
index b127a7a31e4a0fc372f3e4ceb21aefa70fc15cb1..bb11a08aa28e2dbee77f9b32e9d7d4a479a880db 100644 (file)
@@ -11,7 +11,6 @@ import {
   checkNewInstanceFollower,
   checkNewVideoAbuseForModerators,
   checkNewVideoFromSubscription,
-  checkUserRegistered,
   checkVideoAutoBlacklistForModerators,
   checkVideoIsPublished,
   MockInstancesIndex,
@@ -34,7 +33,7 @@ describe('Test moderation notifications', function () {
   let emails: object[] = []
 
   before(async function () {
-    this.timeout(120000)
+    this.timeout(50000)
 
     const res = await prepareNotificationsTest(3)
     emails = res.emails
@@ -60,7 +59,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should not send a notification to moderators on local abuse reported by an admin', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -72,7 +71,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on local video abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -84,7 +83,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on remote video abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -99,7 +98,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on local comment abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -118,7 +117,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on remote comment abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -140,7 +139,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on local account abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const username = 'user' + new Date().getTime()
       const { account } = await servers[0].users.create({ username, password: 'donald' })
@@ -153,7 +152,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on remote account abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const username = 'user' + new Date().getTime()
       const tmpToken = await servers[0].users.generateUserAndToken(username)
@@ -327,32 +326,6 @@ describe('Test moderation notifications', function () {
     })
   })
 
-  describe('New registration', function () {
-    let baseParams: CheckerBaseParams
-
-    before(() => {
-      baseParams = {
-        server: servers[0],
-        emails,
-        socketNotifications: adminNotifications,
-        token: servers[0].accessToken
-      }
-    })
-
-    it('Should send a notification only to moderators when a user registers on the instance', async function () {
-      this.timeout(10000)
-
-      await servers[0].users.register({ username: 'user_45' })
-
-      await waitJobs(servers)
-
-      await checkUserRegistered({ ...baseParams, username: 'user_45', checkType: 'presence' })
-
-      const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
-      await checkUserRegistered({ ...baseParams, ...userOverride, username: 'user_45', checkType: 'absence' })
-    })
-  })
-
   describe('New instance follows', function () {
     const instanceIndexServer = new MockInstancesIndex()
     let config: any
@@ -512,10 +485,14 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should not send video publish notification if auto-blacklisted', async function () {
+      this.timeout(120000)
+
       await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' })
     })
 
     it('Should not send a local user subscription notification if auto-blacklisted', async function () {
+      this.timeout(120000)
+
       await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' })
     })
 
@@ -524,7 +501,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send video published and unblacklist after video unblacklisted', async function () {
-      this.timeout(40000)
+      this.timeout(120000)
 
       await servers[0].blacklist.remove({ videoId: uuid })
 
@@ -537,10 +514,14 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a local user subscription notification after removed from blacklist', async function () {
+      this.timeout(120000)
+
       await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' })
     })
 
     it('Should send a remote user subscription notification after removed from blacklist', async function () {
+      this.timeout(120000)
+
       await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' })
     })
 
@@ -576,7 +557,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () {
-      this.timeout(40000)
+      this.timeout(120000)
 
       // In 2 seconds
       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 (file)
index 0000000..b5a7c2b
--- /dev/null
@@ -0,0 +1,88 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import {
+  CheckerBaseParams,
+  checkRegistrationRequest,
+  checkUserRegistered,
+  MockSmtpServer,
+  prepareNotificationsTest
+} from '@server/tests/shared'
+import { UserNotification } from '@shared/models'
+import { cleanupTests, PeerTubeServer, waitJobs } from '@shared/server-commands'
+
+describe('Test registrations notifications', function () {
+  let server: PeerTubeServer
+  let userToken1: string
+
+  let userNotifications: UserNotification[] = []
+  let adminNotifications: UserNotification[] = []
+  let emails: object[] = []
+
+  let baseParams: CheckerBaseParams
+
+  before(async function () {
+    this.timeout(50000)
+
+    const res = await prepareNotificationsTest(1)
+
+    server = res.servers[0]
+    emails = res.emails
+    userToken1 = res.userAccessToken
+    adminNotifications = res.adminNotifications
+    userNotifications = res.userNotifications
+
+    baseParams = {
+      server,
+      emails,
+      socketNotifications: adminNotifications,
+      token: server.accessToken
+    }
+  })
+
+  describe('New direct registration for moderators', function () {
+
+    before(async function () {
+      await server.config.enableSignup(false)
+    })
+
+    it('Should send a notification only to moderators when a user registers on the instance', async function () {
+      this.timeout(50000)
+
+      await server.registrations.register({ username: 'user_10' })
+
+      await waitJobs([ server ])
+
+      await checkUserRegistered({ ...baseParams, username: 'user_10', checkType: 'presence' })
+
+      const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
+      await checkUserRegistered({ ...baseParams, ...userOverride, username: 'user_10', checkType: 'absence' })
+    })
+  })
+
+  describe('New registration request for moderators', function () {
+
+    before(async function () {
+      await server.config.enableSignup(true)
+    })
+
+    it('Should send a notification on new registration request', async function () {
+      this.timeout(50000)
+
+      const registrationReason = 'my reason'
+      await server.registrations.requestRegistration({ username: 'user_11', registrationReason })
+
+      await waitJobs([ server ])
+
+      await checkRegistrationRequest({ ...baseParams, username: 'user_11', registrationReason, checkType: 'presence' })
+
+      const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
+      await checkRegistrationRequest({ ...baseParams, ...userOverride, username: 'user_11', registrationReason, checkType: 'absence' })
+    })
+  })
+
+  after(async function () {
+    MockSmtpServer.Instance.kill()
+
+    await cleanupTests([ server ])
+  })
+})
index 71ad35a4346edbef1446c4b261a534f6840480cb..869d437d584900459fe30cd767ac915d5826e542 100644 (file)
@@ -120,7 +120,7 @@ describe('Object storage for video static file privacy', function () {
     // ---------------------------------------------------------------------------
 
     it('Should upload a private video and have appropriate object storage ACL', async function () {
-      this.timeout(60000)
+      this.timeout(120000)
 
       {
         const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
@@ -138,7 +138,7 @@ describe('Object storage for video static file privacy', function () {
     })
 
     it('Should upload a public video and have appropriate object storage ACL', async function () {
-      this.timeout(60000)
+      this.timeout(120000)
 
       const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED })
       await waitJobs([ server ])
index 4fa37d0e2eadd31f1e7506da4efcd2d17d0b2a02..d3b3a24475ee16ad0c60b6ee373e5da37b59631c 100644 (file)
@@ -149,7 +149,7 @@ describe('Test config defaults', function () {
       })
 
       it('Should register a user with this default setting', async function () {
-        await server.users.register({ username: 'user_p2p_2' })
+        await server.registrations.register({ username: 'user_p2p_2' })
 
         const userToken = await server.login.getAccessToken('user_p2p_2')
 
@@ -194,7 +194,7 @@ describe('Test config defaults', function () {
       })
 
       it('Should register a user with this default setting', async function () {
-        await server.users.register({ username: 'user_p2p_4' })
+        await server.registrations.register({ username: 'user_p2p_4' })
 
         const userToken = await server.login.getAccessToken('user_p2p_4')
 
index 22446fe0c9c2863ccf8dda6d724af9a8f2e18e87..b91519660dbfbdf19c10cd76ba3f4a224d2f76d2 100644 (file)
@@ -50,6 +50,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
   expect(data.signup.enabled).to.be.true
   expect(data.signup.limit).to.equal(4)
   expect(data.signup.minimumAge).to.equal(16)
+  expect(data.signup.requiresApproval).to.be.false
   expect(data.signup.requiresEmailVerification).to.be.false
 
   expect(data.admin.email).to.equal('admin' + server.internalServerNumber + '@example.com')
@@ -152,6 +153,7 @@ function checkUpdatedConfig (data: CustomConfig) {
 
   expect(data.signup.enabled).to.be.false
   expect(data.signup.limit).to.equal(5)
+  expect(data.signup.requiresApproval).to.be.false
   expect(data.signup.requiresEmailVerification).to.be.false
   expect(data.signup.minimumAge).to.equal(10)
 
@@ -285,6 +287,7 @@ const newCustomConfig: CustomConfig = {
   signup: {
     enabled: false,
     limit: 5,
+    requiresApproval: false,
     requiresEmailVerification: false,
     minimumAge: 10
   },
@@ -468,9 +471,9 @@ describe('Test config', function () {
     this.timeout(5000)
 
     await Promise.all([
-      server.users.register({ username: 'user1' }),
-      server.users.register({ username: 'user2' }),
-      server.users.register({ username: 'user3' })
+      server.registrations.register({ username: 'user1' }),
+      server.registrations.register({ username: 'user2' }),
+      server.registrations.register({ username: 'user3' })
     ])
 
     const data = await server.config.getConfig()
index 3252180085da206670ecae5cd763b796a79d064c..dd971203a050ccf3560311e582eb831cc0c2b45d 100644 (file)
@@ -6,6 +6,7 @@ import { wait } from '@shared/core-utils'
 import { HttpStatusCode } from '@shared/models'
 import {
   cleanupTests,
+  ConfigCommand,
   ContactFormCommand,
   createSingleServer,
   PeerTubeServer,
@@ -23,13 +24,7 @@ describe('Test contact form', function () {
 
     const port = await MockSmtpServer.Instance.collectEmails(emails)
 
-    const overrideConfig = {
-      smtp: {
-        hostname: '127.0.0.1',
-        port
-      }
-    }
-    server = await createSingleServer(1, overrideConfig)
+    server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port))
     await setAccessTokensToServers([ server ])
 
     command = server.contactForm
index 4ab5463fe2d5696bcad683df485b248e018189af..db7aa65bd70f082af429a946259998405977af6b 100644 (file)
@@ -3,7 +3,14 @@
 import { expect } from 'chai'
 import { MockSmtpServer } from '@server/tests/shared'
 import { HttpStatusCode } from '@shared/models'
-import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands'
+import {
+  cleanupTests,
+  ConfigCommand,
+  createSingleServer,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  waitJobs
+} from '@shared/server-commands'
 
 describe('Test emails', function () {
   let server: PeerTubeServer
@@ -24,21 +31,15 @@ describe('Test emails', function () {
     username: 'user_1',
     password: 'super_password'
   }
-  let emailPort: number
 
   before(async function () {
     this.timeout(50000)
 
-    emailPort = await MockSmtpServer.Instance.collectEmails(emails)
+    const emailPort = await MockSmtpServer.Instance.collectEmails(emails)
+    server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort))
 
-    const overrideConfig = {
-      smtp: {
-        hostname: '127.0.0.1',
-        port: emailPort
-      }
-    }
-    server = await createSingleServer(1, overrideConfig)
     await setAccessTokensToServers([ server ])
+    await server.config.enableSignup(true)
 
     {
       const created = await server.users.create({ username: user.username, password: user.password })
@@ -322,6 +323,62 @@ describe('Test emails', function () {
     })
   })
 
+  describe('When verifying a registration email', function () {
+    let registrationId: number
+    let registrationIdEmail: number
+
+    before(async function () {
+      const { id } = await server.registrations.requestRegistration({
+        username: 'request_1',
+        email: 'request_1@example.com',
+        registrationReason: 'tt'
+      })
+      registrationId = id
+    })
+
+    it('Should ask to send the verification email', async function () {
+      this.timeout(10000)
+
+      await server.registrations.askSendVerifyEmail({ email: 'request_1@example.com' })
+
+      await waitJobs(server)
+      expect(emails).to.have.lengthOf(9)
+
+      const email = emails[8]
+
+      expect(email['from'][0]['name']).equal('PeerTube')
+      expect(email['from'][0]['address']).equal('test-admin@127.0.0.1')
+      expect(email['to'][0]['address']).equal('request_1@example.com')
+      expect(email['subject']).contains('Verify')
+
+      const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
+      expect(verificationStringMatches).not.to.be.null
+
+      verificationString = verificationStringMatches[1]
+      expect(verificationString).to.not.be.undefined
+      expect(verificationString).to.have.length.above(2)
+
+      const registrationIdMatches = /registrationId=([0-9]+)/.exec(email['text'])
+      expect(registrationIdMatches).not.to.be.null
+
+      registrationIdEmail = parseInt(registrationIdMatches[1], 10)
+
+      expect(registrationId).to.equal(registrationIdEmail)
+    })
+
+    it('Should not verify the email with an invalid verification string', async function () {
+      await server.registrations.verifyEmail({
+        registrationId: registrationIdEmail,
+        verificationString: verificationString + 'b',
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should verify the email', async function () {
+      await server.registrations.verifyEmail({ registrationId: registrationIdEmail, verificationString })
+    })
+  })
+
   after(async function () {
     MockSmtpServer.Instance.kill()
 
index d882f0bde79fe70e8c65069da6837eaed9adaed1..11c96c4b524c755ce0d841a11b2f1f304d794782 100644 (file)
@@ -106,13 +106,13 @@ describe('Test application behind a reverse proxy', function () {
   it('Should rate limit signup', async function () {
     for (let i = 0; i < 10; i++) {
       try {
-        await server.users.register({ username: 'test' + i })
+        await server.registrations.register({ username: 'test' + i })
       } catch {
         // empty
       }
     }
 
-    await server.users.register({ username: 'test42', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 })
+    await server.registrations.register({ username: 'test42', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 })
   })
 
   it('Should not rate limit failed signup', async function () {
@@ -121,10 +121,10 @@ describe('Test application behind a reverse proxy', function () {
     await wait(7000)
 
     for (let i = 0; i < 3; i++) {
-      await server.users.register({ username: 'test' + i, expectedStatus: HttpStatusCode.CONFLICT_409 })
+      await server.registrations.register({ username: 'test' + i, expectedStatus: HttpStatusCode.CONFLICT_409 })
     }
 
-    await server.users.register({ username: 'test43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
+    await server.registrations.register({ username: 'test43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
 
   })
 
index 643f1a531f8dbd779403ceaf4bc1e6704816ce63..a4443a8ec9d7e87cb4b37b2bb32404045cf1c000 100644 (file)
@@ -1,6 +1,8 @@
+import './oauth'
+import './registrations`'
 import './two-factor'
 import './user-subscriptions'
 import './user-videos'
 import './users'
 import './users-multiple-servers'
-import './users-verification'
+import './users-email-verification'
diff --git a/server/tests/api/users/oauth.ts b/server/tests/api/users/oauth.ts
new file mode 100644 (file)
index 0000000..6a3da5e
--- /dev/null
@@ -0,0 +1,192 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { wait } from '@shared/core-utils'
+import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models'
+import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
+
+describe('Test oauth', function () {
+  let server: PeerTubeServer
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await createSingleServer(1, {
+      rates_limit: {
+        login: {
+          max: 30
+        }
+      }
+    })
+
+    await setAccessTokensToServers([ server ])
+  })
+
+  describe('OAuth client', function () {
+
+    function expectInvalidClient (body: PeerTubeProblemDocument) {
+      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
+      expect(body.error).to.contain('client is invalid')
+      expect(body.type.startsWith('https://')).to.be.true
+      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
+    }
+
+    it('Should create a new client')
+
+    it('Should return the first client')
+
+    it('Should remove the last client')
+
+    it('Should not login with an invalid client id', async function () {
+      const client = { id: 'client', secret: server.store.client.secret }
+      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidClient(body)
+    })
+
+    it('Should not login with an invalid client secret', async function () {
+      const client = { id: server.store.client.id, secret: 'coucou' }
+      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidClient(body)
+    })
+  })
+
+  describe('Login', function () {
+
+    function expectInvalidCredentials (body: PeerTubeProblemDocument) {
+      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
+      expect(body.error).to.contain('credentials are invalid')
+      expect(body.type.startsWith('https://')).to.be.true
+      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
+    }
+
+    it('Should not login with an invalid username', async function () {
+      const user = { username: 'captain crochet', password: server.store.user.password }
+      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidCredentials(body)
+    })
+
+    it('Should not login with an invalid password', async function () {
+      const user = { username: server.store.user.username, password: 'mew_three' }
+      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidCredentials(body)
+    })
+
+    it('Should be able to login', async function () {
+      await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
+    })
+
+    it('Should be able to login with an insensitive username', async function () {
+      const user = { username: 'RoOt', password: server.store.user.password }
+      await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
+
+      const user2 = { username: 'rOoT', password: server.store.user.password }
+      await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
+
+      const user3 = { username: 'ROOt', password: server.store.user.password }
+      await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
+    })
+  })
+
+  describe('Logout', function () {
+
+    it('Should logout (revoke token)', async function () {
+      await server.login.logout({ token: server.accessToken })
+    })
+
+    it('Should not be able to get the user information', async function () {
+      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should not be able to upload a video', async function () {
+      await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should be able to login again', async function () {
+      const body = await server.login.login()
+      server.accessToken = body.access_token
+      server.refreshToken = body.refresh_token
+    })
+
+    it('Should be able to get my user information again', async function () {
+      await server.users.getMyInfo()
+    })
+
+    it('Should have an expired access token', async function () {
+      this.timeout(60000)
+
+      await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
+      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
+
+      await killallServers([ server ])
+      await server.run()
+
+      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should not be able to refresh an access token with an expired refresh token', async function () {
+      await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+
+    it('Should refresh the token', async function () {
+      this.timeout(50000)
+
+      const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
+      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
+
+      await killallServers([ server ])
+      await server.run()
+
+      const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
+      server.accessToken = res.body.access_token
+      server.refreshToken = res.body.refresh_token
+    })
+
+    it('Should be able to get my user information again', async function () {
+      await server.users.getMyInfo()
+    })
+  })
+
+  describe('Custom token lifetime', function () {
+    before(async function () {
+      this.timeout(120_000)
+
+      await server.kill()
+      await server.run({
+        oauth2: {
+          token_lifetime: {
+            access_token: '2 seconds',
+            refresh_token: '2 seconds'
+          }
+        }
+      })
+    })
+
+    it('Should have a very short access token lifetime', async function () {
+      this.timeout(50000)
+
+      const { access_token: accessToken } = await server.login.login()
+      await server.users.getMyInfo({ token: accessToken })
+
+      await wait(3000)
+      await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should have a very short refresh token lifetime', async function () {
+      this.timeout(50000)
+
+      const { refresh_token: refreshToken } = await server.login.login()
+      await server.login.refreshToken({ refreshToken })
+
+      await wait(3000)
+      await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
diff --git a/server/tests/api/users/registrations.ts b/server/tests/api/users/registrations.ts
new file mode 100644 (file)
index 0000000..e6524f0
--- /dev/null
@@ -0,0 +1,415 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { MockSmtpServer } from '@server/tests/shared'
+import { UserRegistrationState, UserRole } from '@shared/models'
+import {
+  cleanupTests,
+  ConfigCommand,
+  createSingleServer,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  waitJobs
+} from '@shared/server-commands'
+
+describe('Test registrations', function () {
+  let server: PeerTubeServer
+
+  const emails: object[] = []
+  let emailPort: number
+
+  before(async function () {
+    this.timeout(30000)
+
+    emailPort = await MockSmtpServer.Instance.collectEmails(emails)
+
+    server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort))
+
+    await setAccessTokensToServers([ server ])
+    await server.config.enableSignup(false)
+  })
+
+  describe('Direct registrations of a new user', function () {
+    let user1Token: string
+
+    it('Should register a new user', async function () {
+      const user = { displayName: 'super user 1', username: 'user_1', password: 'my super password' }
+      const channel = { name: 'my_user_1_channel', displayName: 'my channel rocks' }
+
+      await server.registrations.register({ ...user, channel })
+    })
+
+    it('Should be able to login with this registered user', async function () {
+      const user1 = { username: 'user_1', password: 'my super password' }
+
+      user1Token = await server.login.getAccessToken(user1)
+    })
+
+    it('Should have the correct display name', async function () {
+      const user = await server.users.getMyInfo({ token: user1Token })
+      expect(user.account.displayName).to.equal('super user 1')
+    })
+
+    it('Should have the correct video quota', async function () {
+      const user = await server.users.getMyInfo({ token: user1Token })
+      expect(user.videoQuota).to.equal(5 * 1024 * 1024)
+    })
+
+    it('Should have created the channel', async function () {
+      const { displayName } = await server.channels.get({ channelName: 'my_user_1_channel' })
+
+      expect(displayName).to.equal('my channel rocks')
+    })
+
+    it('Should remove me', async function () {
+      {
+        const { data } = await server.users.list()
+        expect(data.find(u => u.username === 'user_1')).to.not.be.undefined
+      }
+
+      await server.users.deleteMe({ token: user1Token })
+
+      {
+        const { data } = await server.users.list()
+        expect(data.find(u => u.username === 'user_1')).to.be.undefined
+      }
+    })
+  })
+
+  describe('Registration requests', function () {
+    let id2: number
+    let id3: number
+    let id4: number
+
+    let user2Token: string
+    let user3Token: string
+
+    before(async function () {
+      this.timeout(60000)
+
+      await server.config.enableSignup(true)
+
+      {
+        const { id } = await server.registrations.requestRegistration({
+          username: 'user4',
+          registrationReason: 'registration reason 4'
+        })
+
+        id4 = id
+      }
+    })
+
+    it('Should request a registration without a channel', async function () {
+      {
+        const { id } = await server.registrations.requestRegistration({
+          username: 'user2',
+          displayName: 'my super user 2',
+          email: 'user2@example.com',
+          password: 'user2password',
+          registrationReason: 'registration reason 2'
+        })
+
+        id2 = id
+      }
+    })
+
+    it('Should request a registration with a channel', async function () {
+      const { id } = await server.registrations.requestRegistration({
+        username: 'user3',
+        displayName: 'my super user 3',
+        channel: {
+          displayName: 'my user 3 channel',
+          name: 'super_user3_channel'
+        },
+        email: 'user3@example.com',
+        password: 'user3password',
+        registrationReason: 'registration reason 3'
+      })
+
+      id3 = id
+    })
+
+    it('Should list these registration requests', async function () {
+      {
+        const { total, data } = await server.registrations.list({ sort: '-createdAt' })
+        expect(total).to.equal(3)
+        expect(data).to.have.lengthOf(3)
+
+        {
+          expect(data[0].id).to.equal(id3)
+          expect(data[0].username).to.equal('user3')
+          expect(data[0].accountDisplayName).to.equal('my super user 3')
+
+          expect(data[0].channelDisplayName).to.equal('my user 3 channel')
+          expect(data[0].channelHandle).to.equal('super_user3_channel')
+
+          expect(data[0].createdAt).to.exist
+          expect(data[0].updatedAt).to.exist
+
+          expect(data[0].email).to.equal('user3@example.com')
+          expect(data[0].emailVerified).to.be.null
+
+          expect(data[0].moderationResponse).to.be.null
+          expect(data[0].registrationReason).to.equal('registration reason 3')
+          expect(data[0].state.id).to.equal(UserRegistrationState.PENDING)
+          expect(data[0].state.label).to.equal('Pending')
+          expect(data[0].user).to.be.null
+        }
+
+        {
+          expect(data[1].id).to.equal(id2)
+          expect(data[1].username).to.equal('user2')
+          expect(data[1].accountDisplayName).to.equal('my super user 2')
+
+          expect(data[1].channelDisplayName).to.be.null
+          expect(data[1].channelHandle).to.be.null
+
+          expect(data[1].createdAt).to.exist
+          expect(data[1].updatedAt).to.exist
+
+          expect(data[1].email).to.equal('user2@example.com')
+          expect(data[1].emailVerified).to.be.null
+
+          expect(data[1].moderationResponse).to.be.null
+          expect(data[1].registrationReason).to.equal('registration reason 2')
+          expect(data[1].state.id).to.equal(UserRegistrationState.PENDING)
+          expect(data[1].state.label).to.equal('Pending')
+          expect(data[1].user).to.be.null
+        }
+
+        {
+          expect(data[2].username).to.equal('user4')
+        }
+      }
+
+      {
+        const { total, data } = await server.registrations.list({ count: 1, start: 1, sort: 'createdAt' })
+
+        expect(total).to.equal(3)
+        expect(data).to.have.lengthOf(1)
+        expect(data[0].id).to.equal(id2)
+      }
+
+      {
+        const { total, data } = await server.registrations.list({ search: 'user3' })
+        expect(total).to.equal(1)
+        expect(data).to.have.lengthOf(1)
+        expect(data[0].id).to.equal(id3)
+      }
+    })
+
+    it('Should reject a registration request', async function () {
+      await server.registrations.reject({ id: id4, moderationResponse: 'I do not want id 4 on this instance' })
+    })
+
+    it('Should have sent an email to the user explanining the registration has been rejected', async function () {
+      this.timeout(50000)
+
+      await waitJobs([ server ])
+
+      const email = emails.find(e => e['to'][0]['address'] === 'user4@example.com')
+      expect(email).to.exist
+
+      expect(email['subject']).to.contain('been rejected')
+      expect(email['text']).to.contain('been rejected')
+      expect(email['text']).to.contain('I do not want id 4 on this instance')
+    })
+
+    it('Should accept registration requests', async function () {
+      await server.registrations.accept({ id: id2, moderationResponse: 'Welcome id 2' })
+      await server.registrations.accept({ id: id3, moderationResponse: 'Welcome id 3' })
+    })
+
+    it('Should have sent an email to the user explanining the registration has been accepted', async function () {
+      this.timeout(50000)
+
+      await waitJobs([ server ])
+
+      {
+        const email = emails.find(e => e['to'][0]['address'] === 'user2@example.com')
+        expect(email).to.exist
+
+        expect(email['subject']).to.contain('been accepted')
+        expect(email['text']).to.contain('been accepted')
+        expect(email['text']).to.contain('Welcome id 2')
+      }
+
+      {
+        const email = emails.find(e => e['to'][0]['address'] === 'user3@example.com')
+        expect(email).to.exist
+
+        expect(email['subject']).to.contain('been accepted')
+        expect(email['text']).to.contain('been accepted')
+        expect(email['text']).to.contain('Welcome id 3')
+      }
+    })
+
+    it('Should login with these users', async function () {
+      user2Token = await server.login.getAccessToken({ username: 'user2', password: 'user2password' })
+      user3Token = await server.login.getAccessToken({ username: 'user3', password: 'user3password' })
+    })
+
+    it('Should have created the appropriate attributes for user 2', async function () {
+      const me = await server.users.getMyInfo({ token: user2Token })
+
+      expect(me.username).to.equal('user2')
+      expect(me.account.displayName).to.equal('my super user 2')
+      expect(me.videoQuota).to.equal(5 * 1024 * 1024)
+      expect(me.videoChannels[0].name).to.equal('user2_channel')
+      expect(me.videoChannels[0].displayName).to.equal('Main user2 channel')
+      expect(me.role.id).to.equal(UserRole.USER)
+      expect(me.email).to.equal('user2@example.com')
+    })
+
+    it('Should have created the appropriate attributes for user 3', async function () {
+      const me = await server.users.getMyInfo({ token: user3Token })
+
+      expect(me.username).to.equal('user3')
+      expect(me.account.displayName).to.equal('my super user 3')
+      expect(me.videoQuota).to.equal(5 * 1024 * 1024)
+      expect(me.videoChannels[0].name).to.equal('super_user3_channel')
+      expect(me.videoChannels[0].displayName).to.equal('my user 3 channel')
+      expect(me.role.id).to.equal(UserRole.USER)
+      expect(me.email).to.equal('user3@example.com')
+    })
+
+    it('Should list these accepted/rejected registration requests', async function () {
+      const { data } = await server.registrations.list({ sort: 'createdAt' })
+      const { data: users } = await server.users.list()
+
+      {
+        expect(data[0].id).to.equal(id4)
+        expect(data[0].state.id).to.equal(UserRegistrationState.REJECTED)
+        expect(data[0].state.label).to.equal('Rejected')
+
+        expect(data[0].moderationResponse).to.equal('I do not want id 4 on this instance')
+        expect(data[0].user).to.be.null
+
+        expect(users.find(u => u.username === 'user4')).to.not.exist
+      }
+
+      {
+        expect(data[1].id).to.equal(id2)
+        expect(data[1].state.id).to.equal(UserRegistrationState.ACCEPTED)
+        expect(data[1].state.label).to.equal('Accepted')
+
+        expect(data[1].moderationResponse).to.equal('Welcome id 2')
+        expect(data[1].user).to.exist
+
+        const user2 = users.find(u => u.username === 'user2')
+        expect(data[1].user.id).to.equal(user2.id)
+      }
+
+      {
+        expect(data[2].id).to.equal(id3)
+        expect(data[2].state.id).to.equal(UserRegistrationState.ACCEPTED)
+        expect(data[2].state.label).to.equal('Accepted')
+
+        expect(data[2].moderationResponse).to.equal('Welcome id 3')
+        expect(data[2].user).to.exist
+
+        const user3 = users.find(u => u.username === 'user3')
+        expect(data[2].user.id).to.equal(user3.id)
+      }
+    })
+
+    it('Shoulde delete a registration', async function () {
+      await server.registrations.delete({ id: id2 })
+      await server.registrations.delete({ id: id3 })
+
+      const { total, data } = await server.registrations.list()
+      expect(total).to.equal(1)
+      expect(data).to.have.lengthOf(1)
+      expect(data[0].id).to.equal(id4)
+
+      const { data: users } = await server.users.list()
+
+      for (const username of [ 'user2', 'user3' ]) {
+        expect(users.find(u => u.username === username)).to.exist
+      }
+    })
+
+    it('Should be able to prevent email delivery on accept/reject', async function () {
+      this.timeout(50000)
+
+      let id1: number
+      let id2: number
+
+      {
+        const { id } = await server.registrations.requestRegistration({
+          username: 'user7',
+          email: 'user7@example.com',
+          registrationReason: 'tt'
+        })
+        id1 = id
+      }
+      {
+        const { id } = await server.registrations.requestRegistration({
+          username: 'user8',
+          email: 'user8@example.com',
+          registrationReason: 'tt'
+        })
+        id2 = id
+      }
+
+      await server.registrations.accept({ id: id1, moderationResponse: 'tt', preventEmailDelivery: true })
+      await server.registrations.reject({ id: id2, moderationResponse: 'tt', preventEmailDelivery: true })
+
+      await waitJobs([ server ])
+
+      const filtered = emails.filter(e => {
+        const address = e['to'][0]['address']
+        return address === 'user7@example.com' || address === 'user8@example.com'
+      })
+
+      expect(filtered).to.have.lengthOf(0)
+    })
+
+    it('Should request a registration without a channel, that will conflict with an already existing channel', async function () {
+      let id1: number
+      let id2: number
+
+      {
+        const { id } = await server.registrations.requestRegistration({
+          registrationReason: 'tt',
+          username: 'user5',
+          password: 'user5password',
+          channel: {
+            displayName: 'channel 6',
+            name: 'user6_channel'
+          }
+        })
+
+        id1 = id
+      }
+
+      {
+        const { id } = await server.registrations.requestRegistration({
+          registrationReason: 'tt',
+          username: 'user6',
+          password: 'user6password'
+        })
+
+        id2 = id
+      }
+
+      await server.registrations.accept({ id: id1, moderationResponse: 'tt' })
+      await server.registrations.accept({ id: id2, moderationResponse: 'tt' })
+
+      const user5Token = await server.login.getAccessToken('user5', 'user5password')
+      const user6Token = await server.login.getAccessToken('user6', 'user6password')
+
+      const user5 = await server.users.getMyInfo({ token: user5Token })
+      const user6 = await server.users.getMyInfo({ token: user6Token })
+
+      expect(user5.videoChannels[0].name).to.equal('user6_channel')
+      expect(user6.videoChannels[0].name).to.equal('user6_channel-1')
+    })
+  })
+
+  after(async function () {
+    MockSmtpServer.Instance.kill()
+
+    await cleanupTests([ server ])
+  })
+})
similarity index 86%
rename from server/tests/api/users/users-verification.ts
rename to server/tests/api/users/users-email-verification.ts
index 19a8df9e105e4dbb39a1457c75d013fa4cf14217..cb84dc75868cbcfccd050dd8f69737e444589b9f 100644 (file)
@@ -3,9 +3,16 @@
 import { expect } from 'chai'
 import { MockSmtpServer } from '@server/tests/shared'
 import { HttpStatusCode } from '@shared/models'
-import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands'
-
-describe('Test users account verification', function () {
+import {
+  cleanupTests,
+  ConfigCommand,
+  createSingleServer,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  waitJobs
+} from '@shared/server-commands'
+
+describe('Test users email verification', function () {
   let server: PeerTubeServer
   let userId: number
   let userAccessToken: string
@@ -25,14 +32,7 @@ describe('Test users account verification', function () {
     this.timeout(30000)
 
     const port = await MockSmtpServer.Instance.collectEmails(emails)
-
-    const overrideConfig = {
-      smtp: {
-        hostname: '127.0.0.1',
-        port
-      }
-    }
-    server = await createSingleServer(1, overrideConfig)
+    server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port))
 
     await setAccessTokensToServers([ server ])
   })
@@ -40,17 +40,18 @@ describe('Test users account verification', function () {
   it('Should register user and send verification email if verification required', async function () {
     this.timeout(30000)
 
-    await server.config.updateCustomSubConfig({
+    await server.config.updateExistingSubConfig({
       newConfig: {
         signup: {
           enabled: true,
+          requiresApproval: false,
           requiresEmailVerification: true,
           limit: 10
         }
       }
     })
 
-    await server.users.register(user1)
+    await server.registrations.register(user1)
 
     await waitJobs(server)
     expectedEmailsLength++
@@ -127,17 +128,15 @@ describe('Test users account verification', function () {
 
   it('Should register user not requiring email verification if setting not enabled', async function () {
     this.timeout(5000)
-    await server.config.updateCustomSubConfig({
+    await server.config.updateExistingSubConfig({
       newConfig: {
         signup: {
-          enabled: true,
-          requiresEmailVerification: false,
-          limit: 10
+          requiresEmailVerification: false
         }
       }
     })
 
-    await server.users.register(user2)
+    await server.registrations.register(user2)
 
     await waitJobs(server)
     expect(emails).to.have.lengthOf(expectedEmailsLength)
@@ -152,9 +151,7 @@ describe('Test users account verification', function () {
     await server.config.updateCustomSubConfig({
       newConfig: {
         signup: {
-          enabled: true,
-          requiresEmailVerification: true,
-          limit: 10
+          requiresEmailVerification: true
         }
       }
     })
index 421b3ce1682306b43f168afb4c7eb89fb5a36cc5..f1e170971ef0a5e4adb5fdcb875b0d5606cd8769 100644 (file)
@@ -2,15 +2,8 @@
 
 import { expect } from 'chai'
 import { testImage } from '@server/tests/shared'
-import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
-import {
-  cleanupTests,
-  createSingleServer,
-  killallServers,
-  makePutBodyRequest,
-  PeerTubeServer,
-  setAccessTokensToServers
-} from '@shared/server-commands'
+import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
+import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
 
 describe('Test users', function () {
   let server: PeerTubeServer
@@ -39,166 +32,6 @@ describe('Test users', function () {
     await server.plugins.install({ npmName: 'peertube-theme-background-red' })
   })
 
-  describe('OAuth client', function () {
-    it('Should create a new client')
-
-    it('Should return the first client')
-
-    it('Should remove the last client')
-
-    it('Should not login with an invalid client id', async function () {
-      const client = { id: 'client', secret: server.store.client.secret }
-      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
-      expect(body.error).to.contain('client is invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
-    })
-
-    it('Should not login with an invalid client secret', async function () {
-      const client = { id: server.store.client.id, secret: 'coucou' }
-      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
-      expect(body.error).to.contain('client is invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
-    })
-  })
-
-  describe('Login', function () {
-
-    it('Should not login with an invalid username', async function () {
-      const user = { username: 'captain crochet', password: server.store.user.password }
-      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
-      expect(body.error).to.contain('credentials are invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
-    })
-
-    it('Should not login with an invalid password', async function () {
-      const user = { username: server.store.user.username, password: 'mew_three' }
-      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
-      expect(body.error).to.contain('credentials are invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
-    })
-
-    it('Should not be able to upload a video', async function () {
-      token = 'my_super_token'
-
-      await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to follow', async function () {
-      token = 'my_super_token'
-
-      await server.follows.follow({
-        hosts: [ 'http://example.com' ],
-        token,
-        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
-      })
-    })
-
-    it('Should not be able to unfollow')
-
-    it('Should be able to login', async function () {
-      const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
-
-      token = body.access_token
-    })
-
-    it('Should be able to login with an insensitive username', async function () {
-      const user = { username: 'RoOt', password: server.store.user.password }
-      await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
-
-      const user2 = { username: 'rOoT', password: server.store.user.password }
-      await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
-
-      const user3 = { username: 'ROOt', password: server.store.user.password }
-      await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
-    })
-  })
-
-  describe('Logout', function () {
-    it('Should logout (revoke token)', async function () {
-      await server.login.logout({ token: server.accessToken })
-    })
-
-    it('Should not be able to get the user information', async function () {
-      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to upload a video', async function () {
-      await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to rate a video', async function () {
-      const path = '/api/v1/videos/'
-      const data = {
-        rating: 'likes'
-      }
-
-      const options = {
-        url: server.url,
-        path: path + videoId,
-        token: 'wrong token',
-        fields: data,
-        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
-      }
-      await makePutBodyRequest(options)
-    })
-
-    it('Should be able to login again', async function () {
-      const body = await server.login.login()
-      server.accessToken = body.access_token
-      server.refreshToken = body.refresh_token
-    })
-
-    it('Should be able to get my user information again', async function () {
-      await server.users.getMyInfo()
-    })
-
-    it('Should have an expired access token', async function () {
-      this.timeout(60000)
-
-      await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
-      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
-
-      await killallServers([ server ])
-      await server.run()
-
-      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to refresh an access token with an expired refresh token', async function () {
-      await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-    })
-
-    it('Should refresh the token', async function () {
-      this.timeout(50000)
-
-      const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
-      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
-
-      await killallServers([ server ])
-      await server.run()
-
-      const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
-      server.accessToken = res.body.access_token
-      server.refreshToken = res.body.refresh_token
-    })
-
-    it('Should be able to get my user information again', async function () {
-      await server.users.getMyInfo()
-    })
-  })
-
   describe('Creating a user', function () {
 
     it('Should be able to create a new user', async function () {
@@ -512,6 +345,7 @@ describe('Test users', function () {
   })
 
   describe('Updating another user', function () {
+
     it('Should be able to update another user', async function () {
       await server.users.update({
         userId,
@@ -562,13 +396,6 @@ describe('Test users', function () {
     })
   })
 
-  describe('Video blacklists', function () {
-
-    it('Should be able to list my video blacklist', async function () {
-      await server.blacklist.list({ token: userToken })
-    })
-  })
-
   describe('Remove a user', function () {
 
     before(async function () {
@@ -602,59 +429,10 @@ describe('Test users', function () {
     })
   })
 
-  describe('Registering a new user', function () {
-    let user15AccessToken: string
-
-    it('Should register a new user', async function () {
-      const user = { displayName: 'super user 15', username: 'user_15', password: 'my super password' }
-      const channel = { name: 'my_user_15_channel', displayName: 'my channel rocks' }
-
-      await server.users.register({ ...user, channel })
-    })
-
-    it('Should be able to login with this registered user', async function () {
-      const user15 = {
-        username: 'user_15',
-        password: 'my super password'
-      }
-
-      user15AccessToken = await server.login.getAccessToken(user15)
-    })
-
-    it('Should have the correct display name', async function () {
-      const user = await server.users.getMyInfo({ token: user15AccessToken })
-      expect(user.account.displayName).to.equal('super user 15')
-    })
-
-    it('Should have the correct video quota', async function () {
-      const user = await server.users.getMyInfo({ token: user15AccessToken })
-      expect(user.videoQuota).to.equal(5 * 1024 * 1024)
-    })
-
-    it('Should have created the channel', async function () {
-      const { displayName } = await server.channels.get({ channelName: 'my_user_15_channel' })
-
-      expect(displayName).to.equal('my channel rocks')
-    })
-
-    it('Should remove me', async function () {
-      {
-        const { data } = await server.users.list()
-        expect(data.find(u => u.username === 'user_15')).to.not.be.undefined
-      }
-
-      await server.users.deleteMe({ token: user15AccessToken })
-
-      {
-        const { data } = await server.users.list()
-        expect(data.find(u => u.username === 'user_15')).to.be.undefined
-      }
-    })
-  })
-
   describe('User blocking', function () {
-    let user16Id
-    let user16AccessToken
+    let user16Id: number
+    let user16AccessToken: string
+
     const user16 = {
       username: 'user_16',
       password: 'my super password'
index 91291524d38544f11898bdbbb5c8f51230b1f677..dd483f95ecbdd0d654e409648e97256eef4ebad0 100644 (file)
@@ -307,6 +307,7 @@ describe('Test channel synchronizations', function () {
     })
   }
 
-  runSuite('youtube-dl')
+  // FIXME: suite is broken with youtube-dl
+  // runSuite('youtube-dl')
   runSuite('yt-dlp')
 })
index dc47f8a4a41c83b9b3eef8835d8873b5b6aa6f38..e35500b0befefdbb19d545d642617c6c532540b7 100644 (file)
@@ -38,6 +38,8 @@ describe('Test video comments', function () {
     await setDefaultAccountAvatar(server)
 
     userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
+    await setDefaultChannelAvatar(server, 'user1_channel')
+    await setDefaultAccountAvatar(server, userAccessTokenServer1)
 
     command = server.comments
   })
@@ -167,6 +169,13 @@ describe('Test video comments', function () {
       expect(body.data[2].totalReplies).to.equal(0)
     })
 
+    it('Should list the and sort them by total replies', async function () {
+      const body = await command.listThreads({ videoId: videoUUID, sort: 'totalReplies' })
+
+      expect(body.data[2].text).to.equal('my super first comment')
+      expect(body.data[2].totalReplies).to.equal(3)
+    })
+
     it('Should delete a reply', async function () {
       await command.delete({ videoId, commentId: replyToDeleteId })
 
@@ -232,16 +241,34 @@ describe('Test video comments', function () {
       await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
 
       const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
-      expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1)
+      expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1)
+      expect(tree.comment.totalReplies).to.equal(2)
     })
   })
 
   describe('All instance comments', function () {
 
     it('Should list instance comments as admin', async function () {
-      const { data } = await command.listForAdmin({ start: 0, count: 1 })
+      {
+        const { data, total } = await command.listForAdmin({ start: 0, count: 1 })
+
+        expect(total).to.equal(7)
+        expect(data).to.have.lengthOf(1)
+        expect(data[0].text).to.equal('my second answer to thread 4')
+        expect(data[0].account.name).to.equal('root')
+        expect(data[0].account.displayName).to.equal('root')
+        expect(data[0].account.avatars).to.have.lengthOf(2)
+      }
+
+      {
+        const { data, total } = await command.listForAdmin({ start: 1, count: 2 })
 
-      expect(data[0].text).to.equal('my second answer to thread 4')
+        expect(total).to.equal(7)
+        expect(data).to.have.lengthOf(2)
+
+        expect(data[0].account.avatars).to.have.lengthOf(2)
+        expect(data[1].account.avatars).to.have.lengthOf(2)
+      }
     })
 
     it('Should filter instance comments by isLocal', async function () {
index 0583134b2e2be0e8a6756166cfb4ef166f3ac306..5636de45f7144c55be37deeaf6c6fc62c1686344 100644 (file)
@@ -41,7 +41,7 @@ async function checkVideosServer1 (server: PeerTubeServer, idHttp: string, idMag
   const videoTorrent = await server.videos.get({ id: idTorrent })
 
   for (const video of [ videoMagnet, videoTorrent ]) {
-    expect(video.category.label).to.equal('Misc')
+    expect(video.category.label).to.equal('Unknown')
     expect(video.licence.label).to.equal('Unknown')
     expect(video.language.label).to.equal('Unknown')
     expect(video.nsfw).to.be.false
index 6a18cf26a109eb2e63491ab2cac86ca2fde4745c..e8e65338278ebb56bd17d9c3911ede5dfe9f76e3 100644 (file)
@@ -3,6 +3,7 @@
 import { expect } from 'chai'
 import { checkPlaylistFilesWereRemoved, testImage } from '@server/tests/shared'
 import { wait } from '@shared/core-utils'
+import { uuidToShort } from '@shared/extra-utils'
 import {
   HttpStatusCode,
   VideoPlaylist,
@@ -23,7 +24,6 @@ import {
   setDefaultVideoChannel,
   waitJobs
 } from '@shared/server-commands'
-import { uuidToShort } from '@shared/extra-utils'
 
 async function checkPlaylistElementType (
   servers: PeerTubeServer[],
@@ -752,19 +752,6 @@ describe('Test video playlists', function () {
         await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
       }
     })
-
-    it('Should hide the video if it is NSFW', async function () {
-      const body = await commands[0].listVideos({ token: userTokenServer1, playlistId: playlistServer1UUID2, query: { nsfw: 'false' } })
-      expect(body.total).to.equal(3)
-
-      const elements = body.data
-      const element = elements.find(e => e.position === 3)
-
-      expect(element).to.exist
-      expect(element.video).to.be.null
-      expect(element.type).to.equal(VideoPlaylistElementType.UNAVAILABLE)
-    })
-
   })
 
   describe('Managing playlist elements', function () {
index 974bf0011f7861c419fd51fbb6a09a6f9111b971..e964bf0c2c1d5ad2d7bb43a5133a0811b0669d64 100644 (file)
@@ -138,14 +138,14 @@ describe('Official plugin Akismet', function () {
     })
 
     it('Should allow signup', async function () {
-      await servers[0].users.register({
+      await servers[0].registrations.register({
         username: 'user1',
         displayName: 'user 1'
       })
     })
 
     it('Should detect a signup as SPAM', async function () {
-      await servers[0].users.register({
+      await servers[0].registrations.register({
         username: 'user2',
         displayName: 'user 2',
         email: 'akismet-guaranteed-spam@example.com',
index d14587c38608d2fd161279e8c90af3e569dea543..cadd02e8d624f573693587fd464f7e5787594afd 100644 (file)
@@ -30,7 +30,7 @@ describe('Official plugin auto-block videos', function () {
   let port: number
 
   before(async function () {
-    this.timeout(60000)
+    this.timeout(120000)
 
     servers = await createMultipleServers(2)
     await setAccessTokensToServers(servers)
index 440b58bfd4476c76181748176a95c755e0125a82..cfed76e8856c7a2931bd66b842a4f3e2beb2bd8a 100644 (file)
@@ -21,7 +21,7 @@ describe('Official plugin auto-mute', function () {
   let port: number
 
   before(async function () {
-    this.timeout(30000)
+    this.timeout(120000)
 
     servers = await createMultipleServers(2)
     await setAccessTokensToServers(servers)
index 906dab1a3aa6d13a212354f97d74f41bf6e06cbc..7345f728a88a114315bdb5e4a96fcf8cd256d010 100644 (file)
@@ -189,7 +189,7 @@ describe('Test syndication feeds', () => {
         const jsonObj = JSON.parse(json)
         expect(jsonObj.items.length).to.be.equal(1)
         expect(jsonObj.items[0].title).to.equal('my super name for server 1')
-        expect(jsonObj.items[0].author.name).to.equal('root')
+        expect(jsonObj.items[0].author.name).to.equal('Main root channel')
       }
 
       {
@@ -197,7 +197,7 @@ describe('Test syndication feeds', () => {
         const jsonObj = JSON.parse(json)
         expect(jsonObj.items.length).to.be.equal(1)
         expect(jsonObj.items[0].title).to.equal('user video')
-        expect(jsonObj.items[0].author.name).to.equal('john')
+        expect(jsonObj.items[0].author.name).to.equal('Main john channel')
       }
 
       for (const server of servers) {
@@ -223,7 +223,7 @@ describe('Test syndication feeds', () => {
         const jsonObj = JSON.parse(json)
         expect(jsonObj.items.length).to.be.equal(1)
         expect(jsonObj.items[0].title).to.equal('my super name for server 1')
-        expect(jsonObj.items[0].author.name).to.equal('root')
+        expect(jsonObj.items[0].author.name).to.equal('Main root channel')
       }
 
       {
@@ -231,7 +231,7 @@ describe('Test syndication feeds', () => {
         const jsonObj = JSON.parse(json)
         expect(jsonObj.items.length).to.be.equal(1)
         expect(jsonObj.items[0].title).to.equal('user video')
-        expect(jsonObj.items[0].author.name).to.equal('john')
+        expect(jsonObj.items[0].author.name).to.equal('Main john channel')
       }
 
       for (const server of servers) {
index c65b8d3a8f618af61d92712a947fc348549966ec..58bc27661b321bf6d6d46391473f1a7f7f69864b 100644 (file)
@@ -33,7 +33,17 @@ async function register ({
           username: 'kefka',
           email: 'kefka@example.com',
           role: 0,
-          displayName: 'Kefka Palazzo'
+          displayName: 'Kefka Palazzo',
+          adminFlags: 1,
+          videoQuota: 42000,
+          videoQuotaDaily: 42100,
+
+          // Always use new value except for videoQuotaDaily field
+          userUpdater: ({ fieldName, currentValue, newValue }) => {
+            if (fieldName === 'videoQuotaDaily') return currentValue
+
+            return newValue
+          }
         })
       },
       hookTokenValidity: (options) => {
index 3e848c49e0f4a9b02e121d88ffaa769cb4680b35..b10177b452164b6384248e5d2ff6e1679a5734df 100644 (file)
@@ -76,6 +76,12 @@ async function register ({
       return res.json({ serverConfig })
     })
 
+    router.get('/server-listening-config', async (req, res) => {
+      const config = await peertubeHelpers.config.getServerListeningConfig()
+
+      return res.json({ config })
+    })
+
     router.get('/static-route', async (req, res) => {
       const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute()
 
index ceab7b60de3a96284e0ed688aaac6b9b2da9aebf..fad5abf60e4831f00975a230626f06fbd081d513 100644 (file)
@@ -33,7 +33,18 @@ async function register ({
       if (body.id === 'laguna' && body.password === 'laguna password') {
         return Promise.resolve({
           username: 'laguna',
-          email: 'laguna@example.com'
+          email: 'laguna@example.com',
+          displayName: 'Laguna Loire',
+          adminFlags: 1,
+          videoQuota: 42000,
+          videoQuotaDaily: 42100,
+
+          // Always use new value except for videoQuotaDaily field
+          userUpdater: ({ fieldName, currentValue, newValue }) => {
+            if (fieldName === 'videoQuotaDaily') return currentValue
+
+            return newValue
+          }
         })
       }
 
index 19dccf26e8bff975ff23360b281071ab96749db3..5b4d34f1547629d00231c2e3a172cf5a94ab0e23 100644 (file)
@@ -226,16 +226,29 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
     }
   })
 
-  registerHook({
-    target: 'filter:api.user.signup.allowed.result',
-    handler: (result, params) => {
-      if (params && params.body && params.body.email && params.body.email.includes('jma')) {
-        return { allowed: false, errorMessage: 'No jma' }
+  {
+    registerHook({
+      target: 'filter:api.user.signup.allowed.result',
+      handler: (result, params) => {
+        if (params && params.body && params.body.email && params.body.email.includes('jma 1')) {
+          return { allowed: false, errorMessage: 'No jma 1' }
+        }
+
+        return result
       }
+    })
 
-      return result
-    }
-  })
+    registerHook({
+      target: 'filter:api.user.request-signup.allowed.result',
+      handler: (result, params) => {
+        if (params && params.body && params.body.email && params.body.email.includes('jma 2')) {
+          return { allowed: false, errorMessage: 'No jma 2' }
+        }
+
+        return result
+      }
+    })
+  }
 
   registerHook({
     target: 'filter:api.download.torrent.allowed.result',
@@ -250,7 +263,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
 
   registerHook({
     target: 'filter:api.download.video.allowed.result',
-    handler: (result, params) => {
+    handler: async (result, params) => {
+      const loggedInUser = await peertubeHelpers.user.getAuthUser(params.res)
+      if (loggedInUser) return { allowed: true }
+
       if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
         return { allowed: false, errorMessage: 'Cao Cao' }
       }
index 1b5c6d15b90258b7eedb1b3b98998ff57caa6025..073ae64551ef8baecdd6da78fbad08ea078e3f07 100644 (file)
@@ -6,3 +6,4 @@ import './image'
 import './markdown'
 import './request'
 import './validator'
+import './version'
diff --git a/server/tests/helpers/version.ts b/server/tests/helpers/version.ts
new file mode 100644 (file)
index 0000000..2a90efb
--- /dev/null
@@ -0,0 +1,36 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { compareSemVer } from '@shared/core-utils'
+
+describe('Version', function () {
+
+  it('Should correctly compare two stable versions', async function () {
+    expect(compareSemVer('3.4.0', '3.5.0')).to.be.below(0)
+    expect(compareSemVer('3.5.0', '3.4.0')).to.be.above(0)
+
+    expect(compareSemVer('3.4.0', '4.1.0')).to.be.below(0)
+    expect(compareSemVer('4.1.0', '3.4.0')).to.be.above(0)
+
+    expect(compareSemVer('3.4.0', '3.4.1')).to.be.below(0)
+    expect(compareSemVer('3.4.1', '3.4.0')).to.be.above(0)
+  })
+
+  it('Should correctly compare two unstable version', async function () {
+    expect(compareSemVer('3.4.0-alpha', '3.4.0-beta.1')).to.be.below(0)
+    expect(compareSemVer('3.4.0-alpha.1', '3.4.0-beta.1')).to.be.below(0)
+    expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0)
+    expect(compareSemVer('3.4.0-beta.1', '3.5.0-alpha.1')).to.be.below(0)
+
+    expect(compareSemVer('3.4.0-alpha.1', '3.4.0-nightly.4')).to.be.below(0)
+    expect(compareSemVer('3.4.0-nightly.3', '3.4.0-nightly.4')).to.be.below(0)
+    expect(compareSemVer('3.3.0-nightly.5', '3.4.0-nightly.4')).to.be.below(0)
+  })
+
+  it('Should correctly compare a stable and unstable versions', async function () {
+    expect(compareSemVer('3.4.0', '3.4.1-beta.1')).to.be.below(0)
+    expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0)
+    expect(compareSemVer('3.4.0-beta.1', '3.4.0')).to.be.below(0)
+    expect(compareSemVer('3.4.0-nightly.4', '3.4.0')).to.be.below(0)
+  })
+})
index 36f8052c0a1744e7b1f8badb59c500a9ee4b45bb..a266ae7f14e0c59811dff62cc737a74364c204ba 100644 (file)
@@ -153,7 +153,7 @@ describe('Test plugin action hooks', function () {
     let userId: number
 
     it('Should run action:api.user.registered', async function () {
-      await servers[0].users.register({ username: 'registered_user' })
+      await servers[0].registrations.register({ username: 'registered_user' })
 
       await checkHook('action:api.user.registered')
     })
index 437777e90e7a2dfb72f4a975f3809b074319a228..e600f958f89bb68657713cadbf64f17387d2abc0 100644 (file)
@@ -2,7 +2,7 @@
 
 import { expect } from 'chai'
 import { wait } from '@shared/core-utils'
-import { HttpStatusCode, UserRole } from '@shared/models'
+import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
 import {
   cleanupTests,
   createSingleServer,
@@ -51,6 +51,7 @@ describe('Test external auth plugins', function () {
 
   let kefkaAccessToken: string
   let kefkaRefreshToken: string
+  let kefkaId: number
 
   let externalAuthToken: string
 
@@ -156,6 +157,9 @@ describe('Test external auth plugins', function () {
       expect(body.account.displayName).to.equal('cyan')
       expect(body.email).to.equal('cyan@example.com')
       expect(body.role.id).to.equal(UserRole.USER)
+      expect(body.adminFlags).to.equal(UserAdminFlag.NONE)
+      expect(body.videoQuota).to.equal(5242880)
+      expect(body.videoQuotaDaily).to.equal(-1)
     }
   })
 
@@ -178,6 +182,11 @@ describe('Test external auth plugins', function () {
       expect(body.account.displayName).to.equal('Kefka Palazzo')
       expect(body.email).to.equal('kefka@example.com')
       expect(body.role.id).to.equal(UserRole.ADMINISTRATOR)
+      expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)
+      expect(body.videoQuota).to.equal(42000)
+      expect(body.videoQuotaDaily).to.equal(42100)
+
+      kefkaId = body.id
     }
   })
 
@@ -240,6 +249,37 @@ describe('Test external auth plugins', function () {
     expect(body.role.id).to.equal(UserRole.USER)
   })
 
+  it('Should login Kefka and update the profile', async function () {
+    {
+      await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 })
+      await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' })
+
+      const body = await server.users.getMyInfo({ token: kefkaAccessToken })
+      expect(body.username).to.equal('kefka')
+      expect(body.account.displayName).to.equal('kefka updated')
+      expect(body.videoQuota).to.equal(43000)
+      expect(body.videoQuotaDaily).to.equal(43100)
+    }
+
+    {
+      const res = await loginExternal({
+        server,
+        npmName: 'test-external-auth-one',
+        authName: 'external-auth-2',
+        username: 'kefka'
+      })
+
+      kefkaAccessToken = res.access_token
+      kefkaRefreshToken = res.refresh_token
+
+      const body = await server.users.getMyInfo({ token: kefkaAccessToken })
+      expect(body.username).to.equal('kefka')
+      expect(body.account.displayName).to.equal('Kefka Palazzo')
+      expect(body.videoQuota).to.equal(42000)
+      expect(body.videoQuotaDaily).to.equal(43100)
+    }
+  })
+
   it('Should not update an external auth email', async function () {
     await server.users.updateMe({
       token: cyanAccessToken,
index 083fd43ca4bb5e573aa7f5931cd09bd11d926cd2..37eef6cf36385536107853cf990bde8c733bd513 100644 (file)
@@ -1,7 +1,15 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
 import { expect } from 'chai'
-import { HttpStatusCode, VideoDetails, VideoImportState, VideoPlaylist, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models'
+import {
+  HttpStatusCode,
+  PeerTubeProblemDocument,
+  VideoDetails,
+  VideoImportState,
+  VideoPlaylist,
+  VideoPlaylistPrivacy,
+  VideoPrivacy
+} from '@shared/models'
 import {
   cleanupTests,
   createMultipleServers,
@@ -408,28 +416,58 @@ describe('Test plugin filter hooks', function () {
 
   describe('Should run filter:api.user.signup.allowed.result', function () {
 
+    before(async function () {
+      await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: false } } })
+    })
+
     it('Should run on config endpoint', async function () {
       const body = await servers[0].config.getConfig()
       expect(body.signup.allowed).to.be.true
     })
 
     it('Should allow a signup', async function () {
-      await servers[0].users.register({ username: 'john', password: 'password' })
+      await servers[0].registrations.register({ username: 'john1' })
     })
 
     it('Should not allow a signup', async function () {
-      const res = await servers[0].users.register({
-        username: 'jma',
-        password: 'password',
+      const res = await servers[0].registrations.register({
+        username: 'jma 1',
         expectedStatus: HttpStatusCode.FORBIDDEN_403
       })
 
-      expect(res.body.error).to.equal('No jma')
+      expect(res.body.error).to.equal('No jma 1')
+    })
+  })
+
+  describe('Should run filter:api.user.request-signup.allowed.result', function () {
+
+    before(async function () {
+      await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: true } } })
+    })
+
+    it('Should run on config endpoint', async function () {
+      const body = await servers[0].config.getConfig()
+      expect(body.signup.allowed).to.be.true
+    })
+
+    it('Should allow a signup request', async function () {
+      await servers[0].registrations.requestRegistration({ username: 'john2', registrationReason: 'tt' })
+    })
+
+    it('Should not allow a signup request', async function () {
+      const body = await servers[0].registrations.requestRegistration({
+        username: 'jma 2',
+        registrationReason: 'tt',
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+
+      expect((body as unknown as PeerTubeProblemDocument).error).to.equal('No jma 2')
     })
   })
 
   describe('Download hooks', function () {
     const downloadVideos: VideoDetails[] = []
+    let downloadVideo2Token: string
 
     before(async function () {
       this.timeout(120000)
@@ -459,6 +497,8 @@ describe('Test plugin filter hooks', function () {
       for (const uuid of uuids) {
         downloadVideos.push(await servers[0].videos.get({ id: uuid }))
       }
+
+      downloadVideo2Token = await servers[0].videoToken.getVideoFileToken({ videoId: downloadVideos[2].uuid })
     })
 
     it('Should run filter:api.download.torrent.allowed.result', async function () {
@@ -471,32 +511,42 @@ describe('Test plugin filter hooks', function () {
 
     it('Should run filter:api.download.video.allowed.result', async function () {
       {
-        const res = await makeRawRequest({ url: downloadVideos[1].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+        const refused = downloadVideos[1].files[0].fileDownloadUrl
+        const allowed = [
+          downloadVideos[0].files[0].fileDownloadUrl,
+          downloadVideos[2].files[0].fileDownloadUrl
+        ]
+
+        const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
         expect(res.body.error).to.equal('Cao Cao')
 
-        await makeRawRequest({ url: downloadVideos[0].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 })
-        await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 })
+        for (const url of allowed) {
+          await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
+          await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
+        }
       }
 
       {
-        const res = await makeRawRequest({
-          url: downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl,
-          expectedStatus: HttpStatusCode.FORBIDDEN_403
-        })
+        const refused = downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl
 
-        expect(res.body.error).to.equal('Sun Jian')
+        const allowed = [
+          downloadVideos[2].files[0].fileDownloadUrl,
+          downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
+          downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl
+        ]
 
-        await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 })
+        // Only streaming playlist is refuse
+        const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+        expect(res.body.error).to.equal('Sun Jian')
 
-        await makeRawRequest({
-          url: downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
-          expectedStatus: HttpStatusCode.OK_200
-        })
+        // But not we there is a user in res
+        await makeRawRequest({ url: refused, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 })
+        await makeRawRequest({ url: refused, query: { videoFileToken: downloadVideo2Token }, expectedStatus: HttpStatusCode.OK_200 })
 
-        await makeRawRequest({
-          url: downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl,
-          expectedStatus: HttpStatusCode.OK_200
-        })
+        // Other files work
+        for (const url of allowed) {
+          await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
+        }
       }
     })
   })
index fc24a56564b2a5f638446257a333a028c5752b3e..10155c28b667be5087fc4d5b28e56b75c2905d82 100644 (file)
@@ -13,6 +13,7 @@ describe('Test id and pass auth plugins', function () {
 
   let lagunaAccessToken: string
   let lagunaRefreshToken: string
+  let lagunaId: number
 
   before(async function () {
     this.timeout(30000)
@@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () {
       const body = await server.users.getMyInfo({ token: lagunaAccessToken })
 
       expect(body.username).to.equal('laguna')
-      expect(body.account.displayName).to.equal('laguna')
+      expect(body.account.displayName).to.equal('Laguna Loire')
       expect(body.role.id).to.equal(UserRole.USER)
+
+      lagunaId = body.id
     }
   })
 
@@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () {
     expect(body.role.id).to.equal(UserRole.MODERATOR)
   })
 
+  it('Should login Laguna and update the profile', async function () {
+    {
+      await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 })
+      await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' })
+
+      const body = await server.users.getMyInfo({ token: lagunaAccessToken })
+      expect(body.username).to.equal('laguna')
+      expect(body.account.displayName).to.equal('laguna updated')
+      expect(body.videoQuota).to.equal(43000)
+      expect(body.videoQuotaDaily).to.equal(43100)
+    }
+
+    {
+      const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } })
+      lagunaAccessToken = body.access_token
+      lagunaRefreshToken = body.refresh_token
+    }
+
+    {
+      const body = await server.users.getMyInfo({ token: lagunaAccessToken })
+      expect(body.username).to.equal('laguna')
+      expect(body.account.displayName).to.equal('Laguna Loire')
+      expect(body.videoQuota).to.equal(42000)
+      expect(body.videoQuotaDaily).to.equal(43100)
+    }
+  })
+
   it('Should reject token of laguna by the plugin hook', async function () {
     this.timeout(10000)
 
@@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () {
     await server.servers.waitUntilLog('valid username')
 
     await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-    await server.servers.waitUntilLog('valid display name')
+    await server.servers.waitUntilLog('valid displayName')
 
     await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
     await server.servers.waitUntilLog('valid role')
index 038e3f0d6cdd87a11d3e7ec9447bf91477e97adc..e25992723d0759c2f043641b0d909c1b4949d4e5 100644 (file)
@@ -64,6 +64,18 @@ describe('Test plugin helpers', function () {
       await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`)
     })
 
+    it('Should have the correct listening config', async function () {
+      const res = await makeGetRequest({
+        url: servers[0].url,
+        path: '/plugins/test-four/router/server-listening-config',
+        expectedStatus: HttpStatusCode.OK_200
+      })
+
+      expect(res.body.config).to.exist
+      expect(res.body.config.hostname).to.equal('::')
+      expect(res.body.config.port).to.equal(servers[0].port)
+    })
+
     it('Should have the correct config', async function () {
       const res = await makeGetRequest({
         url: servers[0].url,
index e600bd6b2b3e7bb80d783a88687d80835cf1c7f3..6c0688d5aaf82e372d5c9e40fa23e8b66a88a614 100644 (file)
@@ -11,6 +11,7 @@ import {
   UserNotificationType
 } from '@shared/models'
 import {
+  ConfigCommand,
   createMultipleServers,
   doubleFollow,
   PeerTubeServer,
@@ -173,6 +174,8 @@ async function checkMyVideoImportIsFinished (options: CheckerBaseParams & {
   await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
 }
 
+// ---------------------------------------------------------------------------
+
 async function checkUserRegistered (options: CheckerBaseParams & {
   username: string
   checkType: CheckerType
@@ -201,6 +204,36 @@ async function checkUserRegistered (options: CheckerBaseParams & {
   await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
 }
 
+async function checkRegistrationRequest (options: CheckerBaseParams & {
+  username: string
+  registrationReason: string
+  checkType: CheckerType
+}) {
+  const { username, registrationReason } = options
+  const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST
+
+  function notificationChecker (notification: UserNotification, checkType: CheckerType) {
+    if (checkType === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+
+      expect(notification.registration.username).to.equal(username)
+    } else {
+      expect(notification).to.satisfy(n => n.type !== notificationType || n.registration.username !== username)
+    }
+  }
+
+  function emailNotificationFinder (email: object) {
+    const text: string = email['text']
+
+    return text.includes(' wants to register ') && text.includes(username) && text.includes(registrationReason)
+  }
+
+  await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
+}
+
+// ---------------------------------------------------------------------------
+
 async function checkNewActorFollow (options: CheckerBaseParams & {
   followType: 'channel' | 'account'
   followerName: string
@@ -673,10 +706,8 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
   const port = await MockSmtpServer.Instance.collectEmails(emails)
 
   const overrideConfig = {
-    smtp: {
-      hostname: '127.0.0.1',
-      port
-    },
+    ...ConfigCommand.getEmailOverrideConfig(port),
+
     signup: {
       limit: 20
     }
@@ -735,7 +766,8 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
     userAccessToken,
     emails,
     servers,
-    channelId
+    channelId,
+    baseOverrideConfig: overrideConfig
   }
 }
 
@@ -765,7 +797,8 @@ export {
   checkNewAccountAbuseForModerators,
   checkNewPeerTubeVersion,
   checkNewPluginVersion,
-  checkVideoStudioEditionIsFinished
+  checkVideoStudioEditionIsFinished,
+  checkRegistrationRequest
 }
 
 // ---------------------------------------------------------------------------
index c8339584ba7a7c527e31c7acf2a2ca126c62a985..f8ec65752b81530b454d7c636c52f5c61054cb44 100644 (file)
@@ -59,7 +59,7 @@ async function completeVideoCheck (
 
   expect(video.name).to.equal(attributes.name)
   expect(video.category.id).to.equal(attributes.category)
-  expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Misc')
+  expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Unknown')
   expect(video.licence.id).to.equal(attributes.licence)
   expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown')
   expect(video.language.id).to.equal(attributes.language)
index 3738ffc47dd8d716078831e17cbff1405a072347..c1c379b9824a74199801a7ba180e72eff62382d1 100644 (file)
@@ -1,4 +1,3 @@
-
 import { OutgoingHttpHeaders } from 'http'
 import { RegisterServerAuthExternalOptions } from '@server/types'
 import {
@@ -9,7 +8,9 @@ import {
   MActorUrl,
   MChannelBannerAccountDefault,
   MChannelSyncChannel,
+  MRegistration,
   MStreamingPlaylist,
+  MUserAccountUrl,
   MVideoChangeOwnershipFull,
   MVideoFile,
   MVideoFormattableDetails,
@@ -171,6 +172,7 @@ declare module 'express' {
       actorFull?: MActorFull
 
       user?: MUserDefault
+      userRegistration?: MRegistration
 
       server?: MServer
 
@@ -187,6 +189,10 @@ declare module 'express' {
         actor: MActorAccountChannelId
       }
 
+      videoFileToken?: {
+        user: MUserAccountUrl
+      }
+
       authenticated?: boolean
 
       registeredPlugin?: RegisteredPlugin
diff --git a/server/types/lib.d.ts b/server/types/lib.d.ts
new file mode 100644 (file)
index 0000000..c901e20
--- /dev/null
@@ -0,0 +1,12 @@
+type ObjectKeys<T> =
+  T extends object
+    ? `${Exclude<keyof T, symbol>}`[]
+    : T extends number
+      ? []
+      : T extends any | string
+        ? string[]
+        : never
+
+interface ObjectConstructor {
+  keys<T> (o: T): ObjectKeys<T>
+}
index 6657b2128441130471c386600d79158ee9165001..5738f4107cd6e549a30adcc49b8b261557e0e909 100644 (file)
@@ -1,4 +1,5 @@
 export * from './user'
 export * from './user-notification'
 export * from './user-notification-setting'
+export * from './user-registration'
 export * from './user-video-history'
index d4715a0b68e027d12f73032d163d14ebba9f76fd..a732c8aa96c06859df493f149cd6fe566761e95a 100644 (file)
@@ -3,6 +3,7 @@ import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse
 import { ApplicationModel } from '@server/models/application/application'
 import { PluginModel } from '@server/models/server/plugin'
 import { UserNotificationModel } from '@server/models/user/user-notification'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
 import { PickWith, PickWithOpt } from '@shared/typescript-utils'
 import { AbuseModel } from '../../../models/abuse/abuse'
 import { AccountModel } from '../../../models/account/account'
@@ -94,13 +95,16 @@ export module UserNotificationIncludes {
 
   export type ApplicationInclude =
     Pick<ApplicationModel, 'latestPeerTubeVersion'>
+
+  export type UserRegistrationInclude =
+    Pick<UserRegistrationModel, 'id' | 'username'>
 }
 
 // ############################################################################
 
 export type MUserNotification =
   Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' |
-  'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application'>
+  'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application' | 'UserRegistration'>
 
 // ############################################################################
 
@@ -114,4 +118,5 @@ export type UserNotificationModelForApi =
   Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> &
   Use<'Plugin', UserNotificationIncludes.PluginInclude> &
   Use<'Application', UserNotificationIncludes.ApplicationInclude> &
-  Use<'Account', UserNotificationIncludes.AccountIncludeActor>
+  Use<'Account', UserNotificationIncludes.AccountIncludeActor> &
+  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 (file)
index 0000000..216423c
--- /dev/null
@@ -0,0 +1,15 @@
+import { UserRegistrationModel } from '@server/models/user/user-registration'
+import { PickWith } from '@shared/typescript-utils'
+import { MUserId } from './user'
+
+type Use<K extends keyof UserRegistrationModel, M> = PickWith<UserRegistrationModel, K, M>
+
+// ############################################################################
+
+export type MRegistration = Omit<UserRegistrationModel, 'User'>
+
+// ############################################################################
+
+export type MRegistrationFormattable =
+  MRegistration &
+  Use<'User', MUserId>
index 79c18c406c93c4c69629689bba9d0010cbf37ca1..e10968c20f507be8404182eac8f971e169d1b366 100644 (file)
@@ -1,14 +1,33 @@
 import express from 'express'
-import { UserRole } from '@shared/models'
+import { UserAdminFlag, UserRole } from '@shared/models'
 import { MOAuthToken, MUser } from '../models'
 
 export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
 
+export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily'
+
 export interface RegisterServerAuthenticatedResult {
+  // Update the user profile if it already exists
+  // Default behaviour is no update
+  // Introduced in PeerTube >= 5.1
+  userUpdater?: <T> (options: {
+    fieldName: AuthenticatedResultUpdaterFieldName
+    currentValue: T
+    newValue: T
+  }) => T
+
   username: string
   email: string
   role?: UserRole
   displayName?: string
+
+  // PeerTube >= 5.1
+  adminFlags?: UserAdminFlag
+
+  // PeerTube >= 5.1
+  videoQuota?: number
+  // PeerTube >= 5.1
+  videoQuotaDaily?: number
 }
 
 export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
index 1e2bd830ebaf7390c3e71c397bbeef23a6eb97c2..df419fff47239576630c6f7dffcc2dc4e58f3379 100644 (file)
@@ -71,6 +71,9 @@ export type PeerTubeHelpers = {
   config: {
     getWebserverUrl: () => string
 
+    // PeerTube >= 5.1
+    getServerListeningConfig: () => { hostname: string, port: number }
+
     getServerConfig: () => Promise<ServerConfig>
   }
 
index 8a64f8c4d02d4c145c138d0379432191baa36f35..3052872338b1dcd13f8745ff2cc3ca9dd0f9fcae 100644 (file)
@@ -1,18 +1,9 @@
-// Thanks https://stackoverflow.com/a/16187766
+// Thanks https://gist.github.com/iwill/a83038623ba4fef6abb9efca87ae9ccb
 function compareSemVer (a: string, b: string) {
-  const regExStrip0 = /(\.0+)+$/
-  const segmentsA = a.replace(regExStrip0, '').split('.')
-  const segmentsB = b.replace(regExStrip0, '').split('.')
+  if (a.startsWith(b + '-')) return -1
+  if (b.startsWith(a + '-')) return 1
 
-  const l = Math.min(segmentsA.length, segmentsB.length)
-
-  for (let i = 0; i < l; i++) {
-    const diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10)
-
-    if (diff) return diff
-  }
-
-  return segmentsA.length - segmentsB.length
+  return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' })
 }
 
 export {
index 3784969b5cba82eaea695714d48c457bbda85620..96bcc945e895e195eab3317d0cea83290064692e 100644 (file)
@@ -1,3 +1,4 @@
+import { RegisteredExternalAuthConfig } from '@shared/models'
 import { HookType } from '../../models/plugins/hook-type.enum'
 import { isCatchable, isPromise } from '../common/promises'
 
@@ -49,7 +50,12 @@ async function internalRunHook <T> (options: {
   return result
 }
 
+function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) {
+  return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
+}
+
 export {
   getHookType,
-  internalRunHook
+  internalRunHook,
+  getExternalAuthHref
 }
index 50230897939498f7e243ca6321d47fe569f77ba0..877f2ec5505dff05f4a584396551df158de3dddf 100644 (file)
@@ -38,7 +38,11 @@ export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[]
       ...additionalAllowedTags,
       'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img'
     ],
-    allowedSchemes: base.allowedSchemes,
+    allowedSchemes: [
+      ...base.allowedSchemes,
+
+      'mailto'
+    ],
     allowedAttributes: {
       ...base.allowedAttributes,
 
index cc757d779e9176b3bf634c1e36244f43f3c645b7..5f3b9a10f1b570ea53f1dd45abf608053e0a7c1f 100644 (file)
@@ -23,7 +23,8 @@ const userRoleRights: { [ id in UserRole ]: UserRight[] } = {
     UserRight.MANAGE_ACCOUNTS_BLOCKLIST,
     UserRight.MANAGE_SERVERS_BLOCKLIST,
     UserRight.MANAGE_USERS,
-    UserRight.SEE_ALL_COMMENTS
+    UserRight.SEE_ALL_COMMENTS,
+    UserRight.MANAGE_REGISTRATIONS
   ],
 
   [UserRole.USER]: []
index f11d2050b0699abfa69ccac4cfa64899a495f88d..dd9cc3ad6636abb2acc532bb5791481474e9ea9e 100644 (file)
@@ -91,6 +91,10 @@ export const serverFilterHookObject = {
   // Filter result used to check if a user can register on the instance
   'filter:api.user.signup.allowed.result': true,
 
+  // Filter result used to check if a user can send a registration request on the instance
+  // PeerTube >= 5.1
+  'filter:api.user.request-signup.allowed.result': true,
+
   // Filter result used to check if video/torrent download is allowed
   'filter:api.download.video.allowed.result': true,
   'filter:api.download.torrent.allowed.result': true,
@@ -156,6 +160,9 @@ export const serverActionHookObject = {
   'action:api.user.unblocked': true,
   // Fired when a user registered on the instance
   'action:api.user.registered': true,
+  // Fired when a user requested registration on the instance
+  // PeerTube >= 5.1
+  'action:api.user.requested-registration': true,
   // Fired when an admin/moderator created a user
   'action:api.user.created': true,
   // Fired when a user is removed by an admin/moderator
index 7d9d570b1047e4966161f196da122ae7ebb12ec7..846bf6159f9364ba40d2adda3a8e7f582557618f 100644 (file)
@@ -83,6 +83,7 @@ export interface CustomConfig {
   signup: {
     enabled: boolean
     limit: number
+    requiresApproval: boolean
     requiresEmailVerification: boolean
     minimumAge: number
   }
index 3b6d0597ce037a3736882d983700975b79f36abc..d0bd9a00feb19afd89015d6a04d108d3edc59593 100644 (file)
@@ -131,6 +131,7 @@ export interface ServerConfig {
     allowed: boolean
     allowedForCurrentIP: boolean
     requiresEmailVerification: boolean
+    requiresApproval: boolean
     minimumAge: number
   }
 
index 0e70ea0a753e6d220e6dd2307dc8ae03c71e7b31..a39cde1b317ba98b548fbc6c6a31bb94df9ba862 100644 (file)
@@ -39,7 +39,13 @@ export const enum ServerErrorCode {
    */
   INCORRECT_FILES_IN_TORRENT = 'incorrect_files_in_torrent',
 
-  COMMENT_NOT_ASSOCIATED_TO_VIDEO = 'comment_not_associated_to_video'
+  COMMENT_NOT_ASSOCIATED_TO_VIDEO = 'comment_not_associated_to_video',
+
+  MISSING_TWO_FACTOR = 'missing_two_factor',
+  INVALID_TWO_FACTOR = 'invalid_two_factor',
+
+  ACCOUNT_WAITING_FOR_APPROVAL = 'account_waiting_for_approval',
+  ACCOUNT_APPROVAL_REJECTED = 'account_approval_rejected'
 }
 
 /**
@@ -70,5 +76,5 @@ export const enum OAuth2ErrorCode {
    *
    * @see https://github.com/oauthjs/node-oauth2-server/blob/master/lib/errors/invalid-token-error.js
    */
-  INVALID_TOKEN = 'invalid_token',
+  INVALID_TOKEN = 'invalid_token'
 }
index 32f7a441c2dd75c9cd8b8e957cffed6ed3a7d42c..4a050c870c8d475cfe66da44a7941c2e68514cb2 100644 (file)
@@ -1,3 +1,4 @@
+export * from './registration'
 export * from './two-factor-enable-result.model'
 export * from './user-create-result.model'
 export * from './user-create.model'
@@ -6,7 +7,6 @@ export * from './user-login.model'
 export * from './user-notification-setting.model'
 export * from './user-notification.model'
 export * from './user-refresh-token.model'
-export * from './user-register.model'
 export * from './user-right.enum'
 export * from './user-role'
 export * from './user-scoped-token'
diff --git a/shared/models/users/registration/index.ts b/shared/models/users/registration/index.ts
new file mode 100644 (file)
index 0000000..593740c
--- /dev/null
@@ -0,0 +1,5 @@
+export * from './user-register.model'
+export * from './user-registration-request.model'
+export * from './user-registration-state.model'
+export * from './user-registration-update-state.model'
+export * from './user-registration.model'
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 (file)
index 0000000..6c38817
--- /dev/null
@@ -0,0 +1,5 @@
+import { UserRegister } from './user-register.model'
+
+export interface UserRegistrationRequest extends UserRegister {
+  registrationReason: string
+}
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 (file)
index 0000000..e4c835f
--- /dev/null
@@ -0,0 +1,5 @@
+export const enum UserRegistrationState {
+  PENDING = 1,
+  REJECTED = 2,
+  ACCEPTED = 3
+}
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 (file)
index 0000000..a1740dc
--- /dev/null
@@ -0,0 +1,4 @@
+export interface UserRegistrationUpdateState {
+  moderationResponse: string
+  preventEmailDelivery?: boolean
+}
diff --git a/shared/models/users/registration/user-registration.model.ts b/shared/models/users/registration/user-registration.model.ts
new file mode 100644 (file)
index 0000000..0d74dc2
--- /dev/null
@@ -0,0 +1,29 @@
+import { UserRegistrationState } from './user-registration-state.model'
+
+export interface UserRegistration {
+  id: number
+
+  state: {
+    id: UserRegistrationState
+    label: string
+  }
+
+  registrationReason: string
+  moderationResponse: string
+
+  username: string
+  email: string
+  emailVerified: boolean
+
+  accountDisplayName: string
+
+  channelHandle: string
+  channelDisplayName: string
+
+  createdAt: Date
+  updatedAt: Date
+
+  user?: {
+    id: number
+  }
+}
index 0fd7a7181bca331309cae796e04b1dce8e9f505a..294c921bd781c196413134106a480256a9794214 100644 (file)
@@ -32,7 +32,9 @@ export const enum UserNotificationType {
   NEW_PLUGIN_VERSION = 17,
   NEW_PEERTUBE_VERSION = 18,
 
-  MY_VIDEO_STUDIO_EDITION_FINISHED = 19
+  MY_VIDEO_STUDIO_EDITION_FINISHED = 19,
+
+  NEW_USER_REGISTRATION_REQUEST = 20
 }
 
 export interface VideoInfo {
@@ -126,6 +128,11 @@ export interface UserNotification {
     latestVersion: string
   }
 
+  registration?: {
+    id: number
+    username: string
+  }
+
   createdAt: string
   updatedAt: string
 }
index 9c6828aa5f90b822190105ae99147aa9386b4fd6..42e5c8cd637c8737984546d420d3d63bbc06c01a 100644 (file)
@@ -43,5 +43,7 @@ export const enum UserRight {
   MANAGE_VIDEO_FILES = 25,
   RUN_VIDEO_TRANSCODING = 26,
 
-  MANAGE_VIDEO_IMPORTS = 27
+  MANAGE_VIDEO_IMPORTS = 27,
+
+  MANAGE_REGISTRATIONS = 28
 }
index 823fc9e388d6a7dcc0b3611da039c4d79824eb66..35cc2253f7d09ae2cff85637d9f13212fee29a16 100644 (file)
@@ -13,101 +13,87 @@ export class SQLCommand extends AbstractCommand {
     return seq.query(`DELETE FROM "${table}"`, options)
   }
 
-  async getCount (table: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
-
-    const [ { total } ] = await seq.query<{ total: string }>(`SELECT COUNT(*) as total FROM "${table}"`, options)
+  async getVideoShareCount () {
+    const [ { total } ] = await this.selectQuery<{ total: string }>(`SELECT COUNT(*) as total FROM "videoShare"`)
     if (total === null) return 0
 
     return parseInt(total, 10)
   }
 
   async getInternalFileUrl (fileId: number) {
-    return this.selectQuery(`SELECT "fileUrl" FROM "videoFile" WHERE id = ${fileId}`)
-      .then(rows => rows[0].fileUrl as string)
+    return this.selectQuery<{ fileUrl: string }>(`SELECT "fileUrl" FROM "videoFile" WHERE id = :fileId`, { fileId })
+      .then(rows => rows[0].fileUrl)
   }
 
   setActorField (to: string, field: string, value: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options)
+    return this.updateQuery(`UPDATE actor SET ${this.escapeColumnName(field)} = :value WHERE url = :to`, { value, to })
   }
 
   setVideoField (uuid: string, field: string, value: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
+    return this.updateQuery(`UPDATE video SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
   }
 
   setPlaylistField (uuid: string, field: string, value: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE "videoPlaylist" SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
+    return this.updateQuery(`UPDATE "videoPlaylist" SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
   }
 
   async countVideoViewsOf (uuid: string) {
-    const seq = this.getSequelize()
-
     const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' +
-      `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = '${uuid}'`
-
-    const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
-    const [ { total } ] = await seq.query<{ total: number }>(query, options)
+      `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = :uuid`
 
+    const [ { total } ] = await this.selectQuery<{ total: number }>(query, { uuid })
     if (!total) return 0
 
     return forceNumber(total)
   }
 
   getActorImage (filename: string) {
-    return this.selectQuery(`SELECT * FROM "actorImage" WHERE filename = '${filename}'`)
+    return this.selectQuery<{ width: number, height: number }>(`SELECT * FROM "actorImage" WHERE filename = :filename`, { filename })
       .then(rows => rows[0])
   }
 
-  selectQuery (query: string) {
-    const seq = this.getSequelize()
-    const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
+  // ---------------------------------------------------------------------------
 
-    return seq.query<any>(query, options)
+  setPluginVersion (pluginName: string, newVersion: string) {
+    return this.setPluginField(pluginName, 'version', newVersion)
   }
 
-  updateQuery (query: string) {
-    const seq = this.getSequelize()
-    const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE }
+  setPluginLatestVersion (pluginName: string, newVersion: string) {
+    return this.setPluginField(pluginName, 'latestVersion', newVersion)
+  }
 
-    return seq.query(query, options)
+  setPluginField (pluginName: string, field: string, value: string) {
+    return this.updateQuery(
+      `UPDATE "plugin" SET ${this.escapeColumnName(field)} = :value WHERE "name" = :pluginName`,
+      { pluginName, value }
+    )
   }
 
   // ---------------------------------------------------------------------------
 
-  setPluginField (pluginName: string, field: string, value: string) {
+  selectQuery <T extends object> (query: string, replacements: { [id: string]: string | number } = {}) {
     const seq = this.getSequelize()
+    const options = {
+      type: QueryTypes.SELECT as QueryTypes.SELECT,
+      replacements
+    }
 
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE "plugin" SET "${field}" = '${value}' WHERE "name" = '${pluginName}'`, options)
+    return seq.query<T>(query, options)
   }
 
-  setPluginVersion (pluginName: string, newVersion: string) {
-    return this.setPluginField(pluginName, 'version', newVersion)
-  }
+  updateQuery (query: string, replacements: { [id: string]: string | number } = {}) {
+    const seq = this.getSequelize()
+    const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE, replacements }
 
-  setPluginLatestVersion (pluginName: string, newVersion: string) {
-    return this.setPluginField(pluginName, 'latestVersion', newVersion)
+    return seq.query(query, options)
   }
 
   // ---------------------------------------------------------------------------
 
   async getPlaylistInfohash (playlistId: number) {
-    const result = await this.selectQuery('SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = ' + playlistId)
+    const query = 'SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = :playlistId'
+
+    const result = await this.selectQuery<{ p2pMediaLoaderInfohashes: string }>(query, { playlistId })
     if (!result || result.length === 0) return []
 
     return result[0].p2pMediaLoaderInfohashes
@@ -116,19 +102,14 @@ export class SQLCommand extends AbstractCommand {
   // ---------------------------------------------------------------------------
 
   setActorFollowScores (newScore: number) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE "actorFollow" SET "score" = ${newScore}`, options)
+    return this.updateQuery(`UPDATE "actorFollow" SET "score" = :newScore`, { newScore })
   }
 
   setTokenField (accessToken: string, field: string, value: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE "oAuthToken" SET "${field}" = '${value}' WHERE "accessToken" = '${accessToken}'`, options)
+    return this.updateQuery(
+      `UPDATE "oAuthToken" SET ${this.escapeColumnName(field)} = :value WHERE "accessToken" = :accessToken`,
+      { value, accessToken }
+    )
   }
 
   async cleanup () {
@@ -157,4 +138,9 @@ export class SQLCommand extends AbstractCommand {
     return this.sequelize
   }
 
+  private escapeColumnName (columnName: string) {
+    return this.getSequelize().escape(columnName)
+      .replace(/^'/, '"')
+      .replace(/'$/, '"')
+  }
 }
index dc9cf4e015a2bcea704ceec00896fcd98bfecda6..cb0e1a5fbd920f3646f9f4c9d1a3b3022977e4b4 100644 (file)
@@ -199,7 +199,7 @@ function buildRequest (req: request.Test, options: CommonRequestParams) {
   return req.expect((res) => {
     if (options.expectedStatus && res.status !== options.expectedStatus) {
       throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` +
-        `\nThe server responded this error: "${res.body?.error ?? res.text}".\n` +
+        `\nThe server responded: "${res.body?.error ?? res.text}".\n` +
         'You may take a closer look at the logs. To see how to do so, check out this page: ' +
         'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs')
     }
index 1c2315ed15685f249d52fa641360b97a8b5945bf..eb6bb95a5a46971156bec778199b856a4b263b16 100644 (file)
@@ -18,6 +18,33 @@ export class ConfigCommand extends AbstractCommand {
     }
   }
 
+  // ---------------------------------------------------------------------------
+
+  static getEmailOverrideConfig (emailPort: number) {
+    return {
+      smtp: {
+        hostname: '127.0.0.1',
+        port: emailPort
+      }
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+
+  enableSignup (requiresApproval: boolean, limit = -1) {
+    return this.updateExistingSubConfig({
+      newConfig: {
+        signup: {
+          enabled: true,
+          requiresApproval,
+          limit
+        }
+      }
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   disableImports () {
     return this.setImportsEnabled(false)
   }
@@ -44,6 +71,16 @@ export class ConfigCommand extends AbstractCommand {
     })
   }
 
+  // ---------------------------------------------------------------------------
+
+  enableChannelSync () {
+    return this.setChannelSyncEnabled(true)
+  }
+
+  disableChannelSync () {
+    return this.setChannelSyncEnabled(false)
+  }
+
   private setChannelSyncEnabled (enabled: boolean) {
     return this.updateExistingSubConfig({
       newConfig: {
@@ -56,13 +93,7 @@ export class ConfigCommand extends AbstractCommand {
     })
   }
 
-  enableChannelSync () {
-    return this.setChannelSyncEnabled(true)
-  }
-
-  disableChannelSync () {
-    return this.setChannelSyncEnabled(false)
-  }
+  // ---------------------------------------------------------------------------
 
   enableLive (options: {
     allowReplay?: boolean
@@ -142,6 +173,8 @@ export class ConfigCommand extends AbstractCommand {
     })
   }
 
+  // ---------------------------------------------------------------------------
+
   enableStudio () {
     return this.updateExistingSubConfig({
       newConfig: {
@@ -152,6 +185,8 @@ export class ConfigCommand extends AbstractCommand {
     })
   }
 
+  // ---------------------------------------------------------------------------
+
   getConfig (options: OverrideCommandOptions = {}) {
     const path = '/api/v1/config'
 
@@ -304,6 +339,7 @@ export class ConfigCommand extends AbstractCommand {
       signup: {
         enabled: false,
         limit: 5,
+        requiresApproval: true,
         requiresEmailVerification: false,
         minimumAge: 16
       },
index ae1395a74246002bb34fbeabe846b729309958b6..793fae3a85b900ad8d5e4b59791ac04a022b9d4c 100644 (file)
@@ -18,6 +18,7 @@ import {
   BlocklistCommand,
   LoginCommand,
   NotificationsCommand,
+  RegistrationsCommand,
   SubscriptionsCommand,
   TwoFactorCommand,
   UsersCommand
@@ -147,6 +148,7 @@ export class PeerTubeServer {
   views?: ViewsCommand
   twoFactor?: TwoFactorCommand
   videoToken?: VideoTokenCommand
+  registrations?: RegistrationsCommand
 
   constructor (options: { serverNumber: number } | { url: string }) {
     if ((options as any).url) {
@@ -430,5 +432,6 @@ export class PeerTubeServer {
     this.views = new ViewsCommand(this)
     this.twoFactor = new TwoFactorCommand(this)
     this.videoToken = new VideoTokenCommand(this)
+    this.registrations = new RegistrationsCommand(this)
   }
 }
index 1afc02dc1854d9354918d67bfba5e816610ef90f..404756539fbf101c51369da0ec93c0336cd4b3f3 100644 (file)
@@ -4,6 +4,7 @@ export * from './blocklist-command'
 export * from './login'
 export * from './login-command'
 export * from './notifications-command'
+export * from './registrations-command'
 export * from './subscriptions-command'
 export * from './two-factor-command'
 export * 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 (file)
index 0000000..f57f54b
--- /dev/null
@@ -0,0 +1,151 @@
+import { pick } from '@shared/core-utils'
+import { HttpStatusCode, ResultList, UserRegistration, UserRegistrationRequest, UserRegistrationUpdateState } from '@shared/models'
+import { unwrapBody } from '../requests'
+import { AbstractCommand, OverrideCommandOptions } from '../shared'
+
+export class RegistrationsCommand extends AbstractCommand {
+
+  register (options: OverrideCommandOptions & Partial<UserRegistrationRequest> & Pick<UserRegistrationRequest, 'username'>) {
+    const { password = 'password', email = options.username + '@example.com' } = options
+    const path = '/api/v1/users/register'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      fields: {
+        ...pick(options, [ 'username', 'displayName', 'channel' ]),
+
+        password,
+        email
+      },
+      implicitToken: false,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  requestRegistration (
+    options: OverrideCommandOptions & Partial<UserRegistrationRequest> & Pick<UserRegistrationRequest, 'username' | 'registrationReason'>
+  ) {
+    const { password = 'password', email = options.username + '@example.com' } = options
+    const path = '/api/v1/users/registrations/request'
+
+    return unwrapBody<UserRegistration>(this.postBodyRequest({
+      ...options,
+
+      path,
+      fields: {
+        ...pick(options, [ 'username', 'displayName', 'channel', 'registrationReason' ]),
+
+        password,
+        email
+      },
+      implicitToken: false,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    }))
+  }
+
+  // ---------------------------------------------------------------------------
+
+  accept (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) {
+    const { id } = options
+    const path = '/api/v1/users/registrations/' + id + '/accept'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]),
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  reject (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) {
+    const { id } = options
+    const path = '/api/v1/users/registrations/' + id + '/reject'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]),
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
+  delete (options: OverrideCommandOptions & {
+    id: number
+  }) {
+    const { id } = options
+    const path = '/api/v1/users/registrations/' + id
+
+    return this.deleteRequest({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
+  list (options: OverrideCommandOptions & {
+    start?: number
+    count?: number
+    sort?: string
+    search?: string
+  } = {}) {
+    const path = '/api/v1/users/registrations'
+
+    return this.getRequestBody<ResultList<UserRegistration>>({
+      ...options,
+
+      path,
+      query: pick(options, [ 'start', 'count', 'sort', 'search' ]),
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
+  askSendVerifyEmail (options: OverrideCommandOptions & {
+    email: string
+  }) {
+    const { email } = options
+    const path = '/api/v1/users/registrations/ask-send-verify-email'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      fields: { email },
+      implicitToken: false,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  verifyEmail (options: OverrideCommandOptions & {
+    registrationId: number
+    verificationString: string
+  }) {
+    const { registrationId, verificationString } = options
+    const path = '/api/v1/users/registrations/' + registrationId + '/verify-email'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      fields: {
+        verificationString
+      },
+      implicitToken: false,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+}
index 811b9685b42c4c3293a5dfcf5b6500cd3eb3dc8f..8a42fafc817a65503e59ac5f5acad2e68cc0c6d7 100644 (file)
@@ -214,35 +214,6 @@ export class UsersCommand extends AbstractCommand {
     return this.server.login.getAccessToken({ username, password })
   }
 
-  register (options: OverrideCommandOptions & {
-    username: string
-    password?: string
-    displayName?: string
-    email?: string
-    channel?: {
-      name: string
-      displayName: string
-    }
-  }) {
-    const { username, password = 'password', displayName, channel, email = username + '@example.com' } = options
-    const path = '/api/v1/users/register'
-
-    return this.postBodyRequest({
-      ...options,
-
-      path,
-      fields: {
-        username,
-        password,
-        email,
-        displayName,
-        channel
-      },
-      implicitToken: false,
-      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
-    })
-  }
-
   // ---------------------------------------------------------------------------
 
   getMyInfo (options: OverrideCommandOptions = {}) {
index bfa7235a2a91ad6d040f95697d885b1da7efc1d6..f90b7f575f1a8c7440c80040d1f2b0b2377def73 100644 (file)
@@ -1,7 +1,7 @@
 openapi: 3.0.0
 info:
   title: PeerTube
-  version: 4.0.0
+  version: 5.1.0
   contact:
     name: PeerTube Community
     url: https://joinpeertube.org
@@ -1401,22 +1401,44 @@ paths:
         '200':
           description: successful operation
 
-  /api/v1/users/register:
+  /api/v1/users/ask-send-verify-email:
     post:
-      summary: Register a user
-      operationId: registerUser
+      summary: Resend user verification link
+      operationId: resendEmailToVerifyUser
       tags:
         - Users
         - Register
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                email:
+                  type: string
+                  description: User email
       responses:
         '204':
           description: successful operation
+
+  /api/v1/users/registrations/ask-send-verify-email:
+    post:
+      summary: Resend verification link to registration email
+      operationId: resendEmailToVerifyRegistration
+      tags:
+        - Register
       requestBody:
         content:
           application/json:
             schema:
-              $ref: '#/components/schemas/RegisterUser'
-        required: true
+              type: object
+              properties:
+                email:
+                  type: string
+                  description: Registration email
+      responses:
+        '204':
+          description: successful operation
 
   /api/v1/users/{id}/verify-email:
     post:
@@ -1425,6 +1447,7 @@ paths:
       description: |
         Following a user registration, the new user will receive an email asking to click a link
         containing a secret.
+        This endpoint can also be used to verify a new email set in the user account.
       tags:
         - Users
         - Register
@@ -1451,6 +1474,36 @@ paths:
         '404':
           description: user not found
 
+  /api/v1/users/registrations/{registrationId}/verify-email:
+    post:
+      summary: Verify a registration email
+      operationId: verifyRegistrationEmail
+      description: |
+        Following a user registration request, the user will receive an email asking to click a link
+        containing a secret.
+      tags:
+        - Register
+      parameters:
+        - $ref: '#/components/parameters/registrationId'
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                verificationString:
+                  type: string
+                  format: url
+              required:
+                - verificationString
+      responses:
+        '204':
+          description: successful operation
+        '403':
+          description: invalid verification string
+        '404':
+          description: registration not found
+
   /api/v1/users/{id}/two-factor/request:
     post:
       summary: Request two factor auth
@@ -1541,18 +1594,6 @@ paths:
         '404':
           description: user not found
 
-
-  /api/v1/users/ask-send-verify-email:
-    post:
-      summary: Resend user verification link
-      operationId: resendEmailToVerifyUser
-      tags:
-        - Users
-        - Register
-      responses:
-        '204':
-          description: successful operation
-
   /api/v1/users/me:
     get:
       summary: Get my user information
@@ -2037,6 +2078,146 @@ paths:
         '204':
           description: successful operation
 
+  /api/v1/users/register:
+    post:
+      summary: Register a user
+      operationId: registerUser
+      description: Signup has to be enabled and signup approval is not required
+      tags:
+        - Register
+      responses:
+        '204':
+          description: successful operation
+        '400':
+          description: request error
+        '403':
+          description: user registration is not enabled, user limit is reached, registration is not allowed for the ip, requires approval or blocked by a plugin
+        '409':
+          description: 'a user with this username, channel name or email already exists'
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/RegisterUser'
+        required: true
+
+  /api/v1/users/registrations/request:
+    post:
+      summary: Request registration
+      description: Signup has to be enabled and require approval on the instance
+      operationId: requestRegistration
+      tags:
+        - Register
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/UserRegistration'
+        '400':
+          description: request error or signup approval is not enabled on the instance
+        '403':
+          description: user registration is not enabled, user limit is reached, registration is not allowed for the ip or blocked by a plugin
+        '409':
+          description: 'a user or registration with this username, channel name or email already exists'
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/UserRegistrationRequest'
+
+  /api/v1/users/registrations/{registrationId}/accept:
+    post:
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      summary: Accept registration
+      operationId: acceptRegistration
+      tags:
+        - Register
+      parameters:
+        - $ref: '#/components/parameters/registrationId'
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/UserRegistrationAcceptOrReject'
+      responses:
+        '204':
+          description: successful operation
+
+  /api/v1/users/registrations/{registrationId}/reject:
+    post:
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      summary: Reject registration
+      operationId: rejectRegistration
+      tags:
+        - Register
+      parameters:
+        - $ref: '#/components/parameters/registrationId'
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/UserRegistrationAcceptOrReject'
+      responses:
+        '204':
+          description: successful operation
+
+  /api/v1/users/registrations/{registrationId}:
+    delete:
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      summary: Delete registration
+      description: 'Delete the registration entry. It will not remove the user associated with this registration (if any)'
+      operationId: deleteRegistration
+      tags:
+        - Register
+      parameters:
+        - $ref: '#/components/parameters/registrationId'
+      responses:
+        '204':
+          description: successful operation
+
+  /api/v1/users/registrations:
+    get:
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      summary: List registrations
+      operationId: listRegistrations
+      tags:
+        - Register
+      parameters:
+        - $ref: '#/components/parameters/start'
+        - $ref: '#/components/parameters/count'
+        - name: search
+          in: query
+          required: false
+          schema:
+            type: string
+        - name: sort
+          in: query
+          required: false
+          schema:
+            type: string
+            enum:
+            - -createdAt
+            - createdAt
+            - state
+            - -state
+      responses:
+        '204':
+          description: successful operation
+
   /api/v1/videos/ownership:
     get:
       summary: List video ownership changes
@@ -5389,6 +5570,7 @@ components:
         type: string
         enum:
         - createdAt
+
     name:
       name: name
       in: path
@@ -5404,6 +5586,13 @@ components:
       description: Entity id
       schema:
         $ref: '#/components/schemas/id'
+    registrationId:
+      name: registrationId
+      in: path
+      required: true
+      description: Registration ID
+      schema:
+        $ref: '#/components/schemas/id'
     idOrUUID:
       name: id
       in: path
@@ -7724,6 +7913,7 @@ components:
       required:
         - video
         - rating
+
     RegisterUser:
       properties:
         username:
@@ -7754,6 +7944,77 @@ components:
         - password
         - email
 
+    UserRegistrationRequest:
+      allOf:
+        - $ref: '#/components/schemas/RegisterUser'
+        - type: object
+          properties:
+            registrationReason:
+              type: string
+              description: reason for the user to register on the instance
+          required:
+            - registrationReason
+
+    UserRegistrationAcceptOrReject:
+      type: object
+      properties:
+        moderationResponse:
+          type: string
+          description: Moderation response to send to the user
+        preventEmailDelivery:
+          type: boolean
+          description: Set it to true if you don't want PeerTube to send an email to the user
+      required:
+        - moderationResponse
+
+    UserRegistration:
+      properties:
+        id:
+          $ref: '#/components/schemas/id'
+        state:
+          type: object
+          properties:
+            id:
+              type: integer
+              enum:
+                - 1
+                - 2
+                - 3
+              description: 'The registration state (Pending = `1`, Rejected = `2`, Accepted = `3`)'
+            label:
+              type: string
+        registrationReason:
+          type: string
+        moderationResponse:
+          type: string
+          nullable: true
+        username:
+          type: string
+        email:
+          type: string
+          format: email
+        emailVerified:
+          type: boolean
+        accountDisplayName:
+          type: string
+        channelHandle:
+          type: string
+        channelDisplayName:
+          type: string
+        createdAt:
+          type: string
+          format: date-time
+        updatedAt:
+          type: string
+          format: date-time
+        user:
+          type: object
+          nullable: true
+          description: If the registration has been accepted, this is a partial user object created by the registration
+          properties:
+            id:
+              $ref: '#/components/schemas/id'
+
     OAuthClient:
       properties:
         client_id:
index bf53b8080a7481ae97bc62d22d6f4559501aa669..5cf1d5879a28471107432cbeb259ee9ed9d5fb7b 100644 (file)
@@ -2,8 +2,6 @@
 
 :warning: **Warning**: dependencies guide is maintained by the community. Some parts may be outdated! :warning:
 
-Follow the below guides, and check their versions match [required external dependencies versions](https://github.com/Chocobozzz/PeerTube/blob/master/engines.yaml).
-
 Main dependencies version supported by PeerTube:
 
  * `node` >=14.x
index 267863a4d38c34b3ab058be16f2fad64df155b0e..b6990f3e340704cab1e58448554704ea9ec8ea23 100644 (file)
@@ -120,7 +120,7 @@ See the production guide ["What now" section](https://docs.joinpeertube.org/inst
 
 ## Upgrade
 
-**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).
+**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
 
 Pull the latest images:
 
index a1131ced542f4bf3e64a94a473f455c3b8a5debf..9ddab3ece7c89da363e03ea2efeb273de3610735 100644 (file)
@@ -433,7 +433,27 @@ function register (...) {
       username: 'user'
       email: 'user@example.com'
       role: 2
-      displayName: 'User display name'
+      displayName: 'User display name',
+
+      // Custom admin flags (bypass video auto moderation etc.)
+      // https://github.com/Chocobozzz/PeerTube/blob/develop/shared/models/users/user-flag.model.ts
+      // PeerTube >= 5.1
+      adminFlags: 0,
+      // Quota in bytes
+      // PeerTube >= 5.1
+      videoQuota: 1024 * 1024 * 1024, // 1GB
+      // PeerTube >= 5.1
+      videoQuotaDaily: -1, // Unlimited
+
+      // Update the user profile if it already exists
+      // Default behaviour is no update
+      // Introduced in PeerTube >= 5.1
+      userUpdater: ({ fieldName, currentValue, newValue }) => {
+        // Always use new value except for videoQuotaDaily field
+        if (fieldName === 'videoQuotaDaily') return currentValue
+
+        return newValue
+      }
     })
   })
 
index dd57e912088228c1169abee58559b66d9a248a2a..9a84f19a388abe120b32d07b3e117b692abb6c1d 100644 (file)
@@ -177,16 +177,17 @@ $ sudo vim /etc/letsencrypt/renewal/your-domain.com.conf
 
 If 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.
 
-**FreeBSD**
+<details>
+<summary><strong>If using FreeBSD</strong></summary>
+
 On FreeBSD you can use [Dehydrated](https://dehydrated.io/) `security/dehydrated` for [Let's Encrypt](https://letsencrypt.org/)
 
 ```bash
 $ sudo pkg install dehydrated
 ```
+</details>
 
-### :alembic: TCP/IP Tuning
-
-**On Linux**
+### :alembic: Linux TCP/IP Tuning
 
 ```bash
 $ 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
 $ sudo journalctl -feu peertube
 ```
 
-**FreeBSD**
+<details>
+<summary><strong>If using FreeBSD</strong></summary>
+
 On FreeBSD, copy the startup script and update rc.conf:
 
 ```bash
@@ -244,8 +247,10 @@ Run:
 ```bash
 $ sudo service peertube start
 ```
+</details>
 
-### :bricks: OpenRC
+<details>
+<summary><strong>If using OpenRC</strong></summary>
 
 If your OS uses OpenRC, copy the service script:
 
@@ -265,6 +270,7 @@ Run and print last logs:
 $ sudo /etc/init.d/peertube start
 $ tail -f /var/log/peertube/peertube.log
 ```
+</details>
 
 ### :technologist: Administrator
 
@@ -291,16 +297,15 @@ Now your instance is up you can:
 
 **Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
 
-#### Auto
-
-The password it asks is PeerTube's database user password.
+Run the upgrade script (the password it asks is PeerTube's database user password):
 
 ```bash
 $ cd /var/www/peertube/peertube-latest/scripts && sudo -H -u peertube ./upgrade.sh
 $ sudo systemctl restart peertube # Or use your OS command to restart PeerTube if you don't use systemd
 ```
 
-#### Manually
+<details>
+<summary><strong>Prefer manual upgrade?</strong></summary>
 
 Make a SQL backup
 
@@ -346,17 +351,18 @@ $ cd /var/www/peertube && \
     sudo unlink ./peertube-latest && \
     sudo -u peertube ln -s versions/peertube-${VERSION} ./peertube-latest
 ```
+</details>
 
-### Configuration
+### Update PeerTube configuration
 
-You can check for configuration changes, and report them in your `config/production.yaml` file:
+Check for configuration changes, and report them in your `config/production.yaml` file:
 
 ```bash
 $ cd /var/www/peertube/versions
 $ diff -u "$(ls --sort=t | head -2 | tail -1)/config/production.yaml.example" "$(ls --sort=t | head -1)/config/production.yaml.example"
 ```
 
-### nginx
+### Update nginx configuration
 
 Check changes in nginx configuration:
 
@@ -365,7 +371,7 @@ $ cd /var/www/peertube/versions
 $ diff -u "$(ls --sort=t | head -2 | tail -1)/support/nginx/peertube" "$(ls --sort=t | head -1)/support/nginx/peertube"
 ```
 
-### systemd
+### Update systemd service
 
 Check changes in systemd configuration:
 
index 993acf81d43f03ad3efd868f2c47aacd1f7dc14d..8bcd944e3cb50472021196e6b6e178947031871c 100644 (file)
@@ -8,7 +8,6 @@
       "@shared/*": [ "shared/*" ]
     },
     "typeRoots": [
-      "server/typings",
       "node_modules/@types"
     ]
   },
@@ -17,5 +16,5 @@
     { "path": "./server" },
     { "path": "./scripts" }
   ],
-  "files": [ "server.ts", "server/types/express.d.ts" ]
+  "files": [ "server.ts", "server/types/express.d.ts", "server/types/lib.d.ts" ]
 }
index 4093a87fd149e8fc865b330bc0d1928f6ba0fb14..d9541b4d8533c8465efb6c3456b1f0abe3a8eab3 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -9099,7 +9099,7 @@ typedarray@^0.0.6:
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
 
-typescript@^4.0.5:
+typescript@~4.8:
   version "4.8.4"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
   integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==