]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Merge branch 'release/4.0.0' into develop
authorChocobozzz <me@florianbigard.com>
Tue, 7 Dec 2021 12:47:34 +0000 (13:47 +0100)
committerChocobozzz <me@florianbigard.com>
Tue, 7 Dec 2021 12:47:34 +0000 (13:47 +0100)
83 files changed:
.github/actions/reusable-deploy/action.yml [new file with mode: 0644]
.github/actions/reusable-prepare-peertube-build/action.yml [new file with mode: 0644]
.github/actions/reusable-prepare-peertube-run/action.yml [new file with mode: 0644]
.github/workflows/benchmark.yml
.github/workflows/codeql.yml [new file with mode: 0644]
.github/workflows/codeql/codeql-config.yml [new file with mode: 0644]
.github/workflows/docker.yml [new file with mode: 0644]
.github/workflows/nightly.yml [new file with mode: 0644]
.github/workflows/stats.yml
.github/workflows/test.yml
.gitlab-ci.yml [deleted file]
client/src/app/+about/about-instance/about-instance.component.html
client/src/app/+about/about.component.html
client/src/app/+accounts/accounts.component.html
client/src/app/+accounts/accounts.component.scss
client/src/app/+accounts/accounts.component.ts
client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+login/login.component.html
client/src/app/+login/login.component.ts
client/src/app/+video-channels/video-channels.component.html
client/src/app/+video-channels/video-channels.component.ts
client/src/app/+video-channels/video-channels.module.ts
client/src/app/menu/menu.component.html
client/src/app/menu/menu.component.ts
client/src/app/shared/shared-main/account/account.model.ts
client/src/app/shared/shared-moderation/account-block-badges.component.html [new file with mode: 0644]
client/src/app/shared/shared-moderation/account-block-badges.component.scss [new file with mode: 0644]
client/src/app/shared/shared-moderation/account-block-badges.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/blocklist.service.ts
client/src/app/shared/shared-moderation/index.ts
client/src/app/shared/shared-moderation/shared-moderation.module.ts
client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
client/src/assets/player/p2p-media-loader/hls-plugin.ts
client/src/root-helpers/plugins-manager.ts
config/default.yaml
config/production.yaml.example
config/test.yaml
package.json
server/controllers/api/blocklist.ts [new file with mode: 0644]
server/controllers/api/config.ts
server/controllers/api/index.ts
server/controllers/api/plugins.ts
server/controllers/api/users/my-subscriptions.ts
server/controllers/api/videos/upload.ts
server/helpers/actors.ts [new file with mode: 0644]
server/helpers/decache.ts [new file with mode: 0644]
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/lib/blocklist.ts
server/lib/client-html.ts
server/lib/notifier/shared/comment/comment-mention.ts
server/lib/plugins/plugin-manager.ts
server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
server/lib/server-config-manager.ts
server/lib/uploadx.ts [new file with mode: 0644]
server/middlewares/validators/blocklist.ts
server/middlewares/validators/plugins.ts
server/models/account/account-blocklist.ts
server/models/server/server-blocklist.ts
server/tests/api/check-params/blocklist.ts
server/tests/api/check-params/config.ts
server/tests/api/check-params/plugins.ts
server/tests/api/moderation/blocklist.ts
server/tests/api/notifications/user-notifications.ts
server/tests/api/server/config.ts
server/tests/cli/peertube.ts
server/tools/peertube-plugins.ts
shared/extra-utils/server/config-command.ts
shared/extra-utils/server/plugins-command.ts
shared/extra-utils/users/blocklist-command.ts
shared/models/moderation/block-status.model.ts [new file with mode: 0644]
shared/models/moderation/index.ts
shared/models/plugins/client/plugin-selector-id.type.ts
shared/models/plugins/server/api/install-plugin.model.ts
shared/models/server/custom-config.model.ts
shared/models/server/server-config.model.ts
support/doc/api/openapi.yaml
support/doc/development/ci.md [new file with mode: 0644]
support/docker/production/Dockerfile.bullseye
yarn.lock

diff --git a/.github/actions/reusable-deploy/action.yml b/.github/actions/reusable-deploy/action.yml
new file mode 100644 (file)
index 0000000..bc69a2e
--- /dev/null
@@ -0,0 +1,46 @@
+name: "Reusable deploy on builds.joinpeertube.org"
+
+description: "Reusable deploy on builds.joinpeertube.org"
+
+inputs:
+  source:
+    required: true
+    description: "Source file/files/directory/directories to deploy"
+  destination:
+    required: true
+    description: "Destination directory on builds.joinpeertube.org"
+  knownHosts:
+    required: true
+    description: "Known hosts"
+  deployKey:
+    required: true
+    description: "Deploy key"
+  deployUser:
+    required: true
+    description: "Deploy user"
+  deployHost:
+    required: true
+    description: "Deploy host"
+
+
+runs:
+  using: "composite"
+
+  steps:
+    - name: "Deploy"
+      shell: bash
+      run: |
+        mkdir -p ~/.ssh
+        chmod 700  ~/.ssh
+
+        echo "Adding ssh key to known hosts"
+        echo -e "${{ inputs.knownHosts }}" > ~/.ssh/known_hosts;
+
+        eval `ssh-agent -s`
+
+        echo "Adding ssh deploy key"
+        ssh-add <(echo "${{ inputs.deployKey }}");
+
+        echo "Uploading files"
+
+        scp ${{ inputs.source }} ${{ inputs.deployUser }}@${{ inputs.deployHost }}:../../web/${{ inputs.destination }};
diff --git a/.github/actions/reusable-prepare-peertube-build/action.yml b/.github/actions/reusable-prepare-peertube-build/action.yml
new file mode 100644 (file)
index 0000000..41ebf71
--- /dev/null
@@ -0,0 +1,31 @@
+name: "Reusable prepare PeerTube build"
+
+description: "Reusable prepare PeerTube build"
+
+inputs:
+  node-version:
+    required: true
+    description: 'NodeJS version'
+
+runs:
+  using: "composite"
+
+  steps:
+    - name: Use Node.js
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ inputs.node-version }}
+
+    - name: Cache Node.js modules
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.OS }}-node-
+          ${{ runner.OS }}-
+
+    - name: Install dependencies
+      shell: bash
+      run: yarn install --frozen-lockfile
diff --git a/.github/actions/reusable-prepare-peertube-run/action.yml b/.github/actions/reusable-prepare-peertube-run/action.yml
new file mode 100644 (file)
index 0000000..1a6cd2c
--- /dev/null
@@ -0,0 +1,16 @@
+name: "Reusable prepare PeerTube run"
+description: "Reusable prepare PeerTube run"
+
+runs:
+  using: "composite"
+
+  steps:
+    - name: Setup system dependencies
+      shell: bash
+      run: |
+        sudo apt-get install postgresql-client-common redis-tools parallel
+        wget --quiet --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-4.3.1-64bit-static.tar.xz"
+        tar xf ffmpeg-release-4.3.1-64bit-static.tar.xz
+        mkdir -p $HOME/bin
+        cp ffmpeg-*/{ffmpeg,ffprobe} $HOME/bin
+        echo "$HOME/bin" >> $GITHUB_PATH
index 86f675432df916da36c82ac5376fb6043fd11d7a..7e8259d27931dc898ce031f49aae911f4fa44257 100644 (file)
@@ -29,48 +29,15 @@ jobs:
     env:
       PGUSER: peertube
       PGHOST: localhost
-      NODE_PENDING_JOB_WAIT: 500
 
     steps:
       - uses: actions/checkout@v2
 
-      - name: Use Node.js
-        uses: actions/setup-node@v1
+      - uses: './.github/actions/reusable-prepare-peertube-build'
         with:
           node-version: '12.x'
 
-      - name: Setup system dependencies
-        run: |
-          sudo apt-get install postgresql-client-common redis-tools parallel
-          wget --quiet --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-4.3.1-64bit-static.tar.xz"
-          tar xf ffmpeg-release-4.3.1-64bit-static.tar.xz
-          mkdir -p $HOME/bin
-          cp ffmpeg-*/{ffmpeg,ffprobe} $HOME/bin
-          echo "$HOME/bin" >> $GITHUB_PATH
-
-      - name: Cache Node.js modules
-        uses: actions/cache@v2
-        with:
-          path: |
-            **/node_modules
-          key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
-          restore-keys: |
-            ${{ runner.OS }}-node-
-            ${{ runner.OS }}-
-
-      - name: Cache fixtures
-        uses: actions/cache@v2
-        with:
-          path: |
-            fixtures
-          key: ${{ runner.OS }}-fixtures-${{ matrix.test_suite }}-${{ hashFiles('fixtures/*') }}
-          restore-keys: |
-            ${{ runner.OS }}-fixtures-${{ matrix.test_suite }}-
-            ${{ runner.OS }}-fixtures-
-            ${{ runner.OS }}-
-
-      - name: Install dependencies
-        run: yarn install --frozen-lockfile
+      - uses: './.github/actions/reusable-prepare-peertube-run'
 
       - name: Build
         run: |
@@ -111,27 +78,11 @@ jobs:
           cat benchmark.json build-time.json startup-time.json
 
       - name: Upload benchmark result
-        env:
-          STATS_DEPLOYEMENT_KNOWN_HOSTS: ${{ secrets.STATS_DEPLOYEMENT_KNOWN_HOSTS }}
-          STATS_DEPLOYEMENT_KEY: ${{ secrets.STATS_DEPLOYEMENT_KEY }}
-          STATS_DEPLOYEMENT_USER: ${{ secrets.STATS_DEPLOYEMENT_USER }}
-          STATS_DEPLOYEMENT_HOST: ${{ secrets.STATS_DEPLOYEMENT_HOST }}
-        run: |
-          mkdir -p ~/.ssh
-          chmod 700  ~/.ssh
-          if [ ! -z ${STATS_DEPLOYEMENT_KNOWN_HOSTS+x} ]; then
-            echo "Adding ssh key to known hosts"
-            echo -e "${STATS_DEPLOYEMENT_KNOWN_HOSTS}" > ~/.ssh/known_hosts;
-          fi
-
-          eval `ssh-agent -s`
-
-          if [ ! -z ${STATS_DEPLOYEMENT_KEY+x} ]; then
-            echo "Adding ssh reployement key"
-            ssh-add <(echo "${STATS_DEPLOYEMENT_KEY}");
-          fi
-
-          if [ ! -z ${STATS_DEPLOYEMENT_KEY+x} ]; then
-            echo "Uploading files"
-            scp benchmark.json build-time.json startup-time.json ${STATS_DEPLOYEMENT_USER}@${STATS_DEPLOYEMENT_HOST}:../../web/peertube-stats;
-          fi
+        uses: './.github/actions/reusable-deploy'
+        with:
+          source: benchmark.json build-time.json startup-time.json
+          destination: peertube-stats
+          knownHosts: ${{ secrets.STATS_DEPLOYEMENT_KNOWN_HOSTS }}
+          deployKey: ${{ secrets.STATS_DEPLOYEMENT_KEY }}
+          deployUser: ${{ secrets.STATS_DEPLOYEMENT_USER }}
+          deployHost: ${{ secrets.STATS_DEPLOYEMENT_HOST }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644 (file)
index 0000000..8764cdd
--- /dev/null
@@ -0,0 +1,68 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ develop, next ]
+  schedule:
+    - cron: '36 9 * * 5'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'javascript' ]
+        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+        # Learn more about CodeQL language support at https://git.io/codeql-language-support
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      with:
+        languages: ${{ matrix.language }}
+        config-file: ./.github/workflows/codeql/codeql-config.yml
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+        # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
+    # If this step fails, then you should remove it and run the build manually (see below)
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v1
+
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 https://git.io/JvXDl
+
+    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+    #    and modify them (or add more) to build your code if your project
+    #    uses a compiled language
+
+    #- run: |
+    #   make bootstrap
+    #   make release
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/codeql/codeql-config.yml b/.github/workflows/codeql/codeql-config.yml
new file mode 100644 (file)
index 0000000..8b771ae
--- /dev/null
@@ -0,0 +1,4 @@
+name: "PeerTube CodeQL config"
+
+paths-ignore:
+ - server/tests
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644 (file)
index 0000000..7afe641
--- /dev/null
@@ -0,0 +1,70 @@
+name: Docker
+
+on:
+  push:
+    branches:
+      - 'master'
+  schedule:
+    - cron: '0 3 * * *'
+
+jobs:
+  generate-matrix:
+    name: Generate matrix for Docker build
+    runs-on: ubuntu-latest
+    outputs:
+      matrix: ${{ steps.set-matrix.outputs.matrix }}
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          ref: master
+      - name: Set matrix for build
+        id: set-matrix
+        run: |
+          # FIXME: https://github.com/actions/checkout/issues/290
+          git fetch --force --tags
+
+          one="{ \"file\": \"./support/docker/production/Dockerfile.bullseye\", \"ref\": \"develop\", \"tags\": \"chocobozzz/peertube:develop-bullseye\" }"
+          two="{ \"file\": \"./support/docker/production/Dockerfile.buster\", \"ref\": \"master\", \"tags\": \"chocobozzz/peertube:production-buster,chocobozzz/peertube:$(git describe --abbrev=0)-buster\" }"
+          three="{ \"file\": \"./support/docker/production/Dockerfile.nginx\", \"ref\": \"master\", \"tags\": \"chocobozzz/peertube-webserver:latest\" }"
+
+          matrix="[$one,$two,$three]"
+          echo ::set-output name=matrix::{\"include\":$(echo $matrix)}
+
+  docker:
+    runs-on: ubuntu-latest
+
+    needs: generate-matrix
+
+    strategy:
+      matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
+      fail-fast: false
+
+    steps:
+      -
+        name: Set up QEMU
+        uses: docker/setup-qemu-action@v1
+      -
+        name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v1
+      -
+        name: Login to DockerHub
+        uses: docker/login-action@v1
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      -
+        name: Checkout develop
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ matrix.ref }}
+      -
+        name: Docker build
+        uses: docker/build-push-action@v2
+        with:
+          context: '.'
+          platforms: linux/amd64,linux/arm64
+          push: true
+          file: ${{ matrix.file }}
+          tags: ${{ matrix.tags }}
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
new file mode 100644 (file)
index 0000000..23898b7
--- /dev/null
@@ -0,0 +1,33 @@
+name: Nightly
+
+on:
+  schedule:
+    - cron: '0 3 * * *'
+
+jobs:
+
+  nightly:
+    runs-on: ubuntu-latest
+
+    steps:
+      -
+        name: Checkout develop
+        uses: actions/checkout@v2
+        with:
+          ref: develop
+
+      - uses: './.github/actions/reusable-prepare-peertube-build'
+        with:
+          node-version: '14.x'
+
+      - name: Build
+        run: npm run nightly
+
+      - uses: './.github/actions/reusable-deploy'
+        with:
+          source: ./peertube-nightly-*
+          destination: nightly
+          knownHosts: ${{ secrets.STATS_DEPLOYEMENT_KNOWN_HOSTS }}
+          deployKey: ${{ secrets.STATS_DEPLOYEMENT_KEY }}
+          deployUser: ${{ secrets.STATS_DEPLOYEMENT_USER }}
+          deployHost: ${{ secrets.STATS_DEPLOYEMENT_HOST }}
index e211f6a3b05cf54b869a83ed177982a147d15524..c87e6fb7759b8532a44f8506616d34fec4982b25 100644 (file)
@@ -1,4 +1,4 @@
-name: "Stats"
+name: Stats
 
 on:
   push:
@@ -20,24 +20,10 @@ jobs:
     steps:
       - uses: actions/checkout@v2
 
-      - name: Use Node.js
-        uses: actions/setup-node@v1
+      - uses: './.github/actions/reusable-prepare-peertube-build'
         with:
           node-version: '14.x'
 
-      - name: Cache Node.js modules
-        uses: actions/cache@v2
-        with:
-          path: |
-            **/node_modules
-          key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
-          restore-keys: |
-            ${{ runner.OS }}-node-
-            ${{ runner.OS }}-
-
-      - name: Install dependencies
-        run: yarn install --frozen-lockfile
-
       - name: Angular bundlewatch
         uses: jackyef/bundlewatch-gh-action@master
         with:
@@ -73,27 +59,11 @@ jobs:
 
       - name: Upload stats
         if: github.event_name != 'pull_request'
-        env:
-          STATS_DEPLOYEMENT_KNOWN_HOSTS: ${{ secrets.STATS_DEPLOYEMENT_KNOWN_HOSTS }}
-          STATS_DEPLOYEMENT_KEY: ${{ secrets.STATS_DEPLOYEMENT_KEY }}
-          STATS_DEPLOYEMENT_USER: ${{ secrets.STATS_DEPLOYEMENT_USER }}
-          STATS_DEPLOYEMENT_HOST: ${{ secrets.STATS_DEPLOYEMENT_HOST }}
-        run: |
-          mkdir -p ~/.ssh
-          chmod 700  ~/.ssh
-          if [ ! -z ${STATS_DEPLOYEMENT_KNOWN_HOSTS+x} ]; then
-            echo "Adding ssh key to known hosts"
-            echo -e "${STATS_DEPLOYEMENT_KNOWN_HOSTS}" > ~/.ssh/known_hosts;
-          fi
-
-          eval `ssh-agent -s`
-
-          if [ ! -z ${STATS_DEPLOYEMENT_KEY+x} ]; then
-            echo "Adding ssh reployement key"
-            ssh-add <(echo "${STATS_DEPLOYEMENT_KEY}");
-          fi
-
-          if [ ! -z ${STATS_DEPLOYEMENT_KEY+x} ]; then
-            echo "Uploading files"
-            scp lighthouse.json client-build-stats.json scc.json ${STATS_DEPLOYEMENT_USER}@${STATS_DEPLOYEMENT_HOST}:../../web/peertube-stats;
-          fi
+        uses: './.github/actions/reusable-deploy'
+        with:
+          source: lighthouse.json client-build-stats.json scc.json
+          destination: peertube-stats
+          knownHosts: ${{ secrets.STATS_DEPLOYEMENT_KNOWN_HOSTS }}
+          deployKey: ${{ secrets.STATS_DEPLOYEMENT_KEY }}
+          deployUser: ${{ secrets.STATS_DEPLOYEMENT_USER }}
+          deployHost: ${{ secrets.STATS_DEPLOYEMENT_HOST }}
index 78a9a28c03e7b098e3c4196f092265864d1886b9..030ec37908216b1c8287eb35b79e5625f402c3ee 100644 (file)
@@ -1,4 +1,4 @@
-name: Test Suite
+name: Test
 
 on:
   push:
@@ -50,29 +50,11 @@ jobs:
     steps:
       - uses: actions/checkout@v2
 
-      - name: Use Node.js
-        uses: actions/setup-node@v1
+      - uses: './.github/actions/reusable-prepare-peertube-build'
         with:
           node-version: '12.x'
 
-      - name: Setup system dependencies
-        run: |
-          sudo apt-get install postgresql-client-common redis-tools parallel
-          wget --quiet --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-4.3.1-64bit-static.tar.xz"
-          tar xf ffmpeg-release-4.3.1-64bit-static.tar.xz
-          mkdir -p $HOME/bin
-          cp ffmpeg-*/{ffmpeg,ffprobe} $HOME/bin
-          echo "$HOME/bin" >> $GITHUB_PATH
-
-      - name: Cache Node.js modules
-        uses: actions/cache@v2
-        with:
-          path: |
-            **/node_modules
-          key: ${{ runner.OS }}-node-${{ hashFiles('**/yarn.lock') }}
-          restore-keys: |
-            ${{ runner.OS }}-node-
-            ${{ runner.OS }}-
+      - uses: './.github/actions/reusable-prepare-peertube-run'
 
       - name: Cache fixtures
         uses: actions/cache@v2
@@ -85,9 +67,6 @@ jobs:
             ${{ runner.OS }}-fixtures-
             ${{ runner.OS }}-
 
-      - name: Install dependencies
-        run: yarn install --frozen-lockfile
-
       - name: Set env test variable (schedule)
         if: github.event_name != 'schedule'
         run: |
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644 (file)
index ad94c8c..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-image: chocobozzz/peertube-ci:14
-
-stages:
-  - clients
-  - docker-nightly
-
-cache:
-  key: yarn
-  paths:
-    - .yarn-cache
-    - cached-fixtures
-
-# build-openapi-clients:
-#   stage: clients
-#   only:
-#     refs:
-#       - master
-#       - schedules
-#     changes:
-#       - support/doc/api/openapi.yaml
-#   script:
-#     - apt-get update -qq
-#     - apt-get -yqqq install openjdk-8-jre
-#     - yarn install --pure-lockfile
-#     - scripts/openapi-peertube-version.sh
-#     - scripts/openapi-clients.sh
-
-build-nightly:
-  stage: docker-nightly
-  only:
-    - schedules
-  script:
-    - yarn install --pure-lockfile --cache-folder .yarn-cache
-    - npm run nightly
-    - mkdir "${HOME}/.ssh"
-    - chmod 700 "${HOME}/.ssh"
-    - if [ ! -z ${DEPLOYEMENT_KNOWN_HOSTS+x} ]; then echo -e "${DEPLOYEMENT_KNOWN_HOSTS}" > ${HOME}/.ssh/known_hosts; fi
-    - eval `ssh-agent -s`
-    - if [ ! -z ${DEPLOYEMENT_KEY+x} ]; then ssh-add <(echo "${DEPLOYEMENT_KEY}"); fi
-    - if [ ! -z ${DEPLOYEMENT_KEY+x} ]; then scp ./peertube-nightly-* ${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:../../web/nightly; fi
-
-.docker: &docker
-  stage: docker-nightly
-  cache: {}
-  image:
-    name: gcr.io/kaniko-project/executor:debug
-    entrypoint: [""]
-  before_script:
-    - mkdir -p /kaniko/.docker
-    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$CI_REGISTRY_AUTH\",\"email\":\"$CI_REGISTRY_EMAIL\"}}}" > /kaniko/.docker/config.json
-  script:
-    - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $DOCKERFILE --destination $DOCKER_IMAGE_NAME
-
-build-docker-develop:
-  <<: *docker
-  only:
-    - schedules
-  variables:
-    DOCKER_IMAGE_NAME: chocobozzz/peertube:develop-bullseye
-    DOCKERFILE: $CI_PROJECT_DIR/support/docker/production/Dockerfile.bullseye
-
-build-docker-webserver:
-  <<: *docker
-  only:
-    - schedules
-  variables:
-    DOCKER_IMAGE_NAME: chocobozzz/peertube-webserver
-    DOCKERFILE: $CI_PROJECT_DIR/support/docker/production/Dockerfile.nginx
-
-build-docker-tag:
-  <<: *docker
-  only:
-    - tags
-  variables:
-    DOCKER_IMAGE_NAME: chocobozzz/peertube:$CI_COMMIT_TAG-bullseye
-    DOCKERFILE: $CI_PROJECT_DIR/support/docker/production/Dockerfile.bullseye
-
-build-docker-master:
-  <<: *docker
-  only:
-    - master
-  variables:
-    DOCKER_IMAGE_NAME: chocobozzz/peertube:production-bullseye
-    DOCKERFILE: $CI_PROJECT_DIR/support/docker/production/Dockerfile.bullseye
index 1026c4e0da506df49292def0cde68cec724e028c..7f2a6aa772eccb56f65b5efee8d2583179a77aa0 100644 (file)
       <my-custom-markup-container [content]="descriptionContent"></my-custom-markup-container>
     </div>
 
-    <div class="anchor" id="moderation"></div>
-    <a
-      *ngIf="html.moderationInformation || html.codeOfConduct || html.terms"
-      class="anchor-link"
-      routerLink="/about/instance"
-      fragment="moderation"
-      #anchorLink
-      (click)="onClickCopyLink(anchorLink)">
-      <h2 i18n class="middle-title">
-        MODERATION
-      </h2>
-    </a>
-
-    <div class="block moderation-information" *ngIf="html.moderationInformation">
-      <div class="anchor" id="moderation-information"></div>
+    <div myPluginSelector pluginSelectorId="about-instance-moderation">
+      <div class="anchor" id="moderation"></div>
       <a
+        *ngIf="html.moderationInformation || html.codeOfConduct || html.terms"
         class="anchor-link"
         routerLink="/about/instance"
-        fragment="moderation-information"
+        fragment="moderation"
         #anchorLink
         (click)="onClickCopyLink(anchorLink)">
-        <h3 i18n class="section-title">Moderation information</h3>
+        <h2 i18n class="middle-title">
+          MODERATION
+        </h2>
       </a>
 
-      <div [innerHTML]="html.moderationInformation"></div>
-    </div>
+      <div class="block moderation-information" *ngIf="html.moderationInformation">
+        <div class="anchor" id="moderation-information"></div>
+        <a
+          class="anchor-link"
+          routerLink="/about/instance"
+          fragment="moderation-information"
+          #anchorLink
+          (click)="onClickCopyLink(anchorLink)">
+          <h3 i18n class="section-title">Moderation information</h3>
+        </a>
 
-    <div class="block code-of-conduct" *ngIf="html.codeOfConduct">
-      <div class="anchor" id="code-of-conduct"></div>
-      <a
-        class="anchor-link"
-        routerLink="/about/instance"
-        fragment="code-of-conduct"
-        #anchorLink
-        (click)="onClickCopyLink(anchorLink)">
-        <h3 i18n class="section-title">Code of conduct</h3>
-      </a>
+        <div [innerHTML]="html.moderationInformation"></div>
+      </div>
 
-      <div [innerHTML]="html.codeOfConduct"></div>
-    </div>
+      <div class="block code-of-conduct" *ngIf="html.codeOfConduct">
+        <div class="anchor" id="code-of-conduct"></div>
+        <a
+          class="anchor-link"
+          routerLink="/about/instance"
+          fragment="code-of-conduct"
+          #anchorLink
+          (click)="onClickCopyLink(anchorLink)">
+          <h3 i18n class="section-title">Code of conduct</h3>
+        </a>
 
-    <div class="block terms">
-      <div class="anchor" id="terms"></div>
-      <a
-        class="anchor-link"
-        routerLink="/about/instance"
-        fragment="terms"
-        #anchorLink
-        (click)="onClickCopyLink(anchorLink)">
-        <h3 i18n class="section-title">Terms</h3>
-      </a>
+        <div [innerHTML]="html.codeOfConduct"></div>
+      </div>
 
-      <div [innerHTML]="html.terms"></div>
-    </div>
+      <div class="block terms">
+        <div class="anchor" id="terms"></div>
+        <a
+          class="anchor-link"
+          routerLink="/about/instance"
+          fragment="terms"
+          #anchorLink
+          (click)="onClickCopyLink(anchorLink)">
+          <h3 i18n class="section-title">Terms</h3>
+        </a>
 
-    <div class="anchor" id="other-information"></div>
-    <a
-      *ngIf="html.hardwareInformation"
-      class="anchor-link"
-      routerLink="/about/instance"
-      fragment="other-information"
-      #anchorLink
-      (click)="onClickCopyLink(anchorLink)">
-      <h2 i18n class="middle-title">
-        OTHER INFORMATION
-      </h2>
-    </a>
+        <div [innerHTML]="html.terms"></div>
+      </div>
+    </div>
 
-    <div class="block hardware-information" *ngIf="html.hardwareInformation">
-      <div class="anchor" id="hardware-information"></div>
+    <div myPluginSelector pluginSelectorId="about-instance-other-information">
+      <div class="anchor" id="other-information"></div>
       <a
+        *ngIf="html.hardwareInformation"
         class="anchor-link"
         routerLink="/about/instance"
-        fragment="hardware-information"
+        fragment="other-information"
         #anchorLink
         (click)="onClickCopyLink(anchorLink)">
-        <h3 i18n class="section-title">Hardware information</h3>
+        <h2 i18n class="middle-title">
+          OTHER INFORMATION
+        </h2>
       </a>
 
-      <div [innerHTML]="html.hardwareInformation"></div>
+      <div class="block hardware-information" *ngIf="html.hardwareInformation">
+        <div class="anchor" id="hardware-information"></div>
+        <a
+          class="anchor-link"
+          routerLink="/about/instance"
+          fragment="hardware-information"
+          #anchorLink
+          (click)="onClickCopyLink(anchorLink)">
+          <h3 i18n class="section-title">Hardware information</h3>
+        </a>
+
+        <div [innerHTML]="html.hardwareInformation"></div>
+      </div>
     </div>
   </div>
 
-  <div class="col-md-12 col-xl-6">
+  <div class="col-md-12 col-xl-6" myPluginSelector pluginSelectorId="about-instance-features">
     <h2 class="sr-only" i18n>FEATURES</h2>
     <my-instance-features-table></my-instance-features-table>
   </div>
 
-  <div class="col">
+  <div class="col" myPluginSelector pluginSelectorId="about-instance-statistics">
     <div class="anchor" id="statistics"></div>
     <a
       class="anchor-link"
index 1ab00c5df4f72db0d40395a75ce2fd0736426ad2..63d429ebfb5be2d220013ec6353e86d50f6680e1 100644 (file)
@@ -2,11 +2,11 @@
   <div class="sub-menu" [ngClass]="{ 'sub-menu-fixed': !isBroadcastMessageDisplayed }">
 
     <div class="links">
-      <a i18n routerLink="instance" routerLinkActive="active" class="title-page title-page-about">Instance</a>
+      <a myPluginSelector pluginSelectorId="about-menu-instance" i18n routerLink="instance" routerLinkActive="active" class="title-page title-page-about">Instance</a>
 
-      <a i18n routerLink="peertube" routerLinkActive="active" class="title-page title-page-about">PeerTube</a>
+      <a myPluginSelector pluginSelectorId="about-menu-peertube" i18n routerLink="peertube" routerLinkActive="active" class="title-page title-page-about">PeerTube</a>
 
-      <a i18n routerLink="follows" routerLinkActive="active" class="title-page title-page-about">Network</a>
+      <a myPluginSelector pluginSelectorId="about-menu-network" i18n routerLink="follows" routerLinkActive="active" class="title-page title-page-about">Network</a>
     </div>
   </div>
 
index 0bb24de2ee25b839b5e8401d9979c4a974089d22..8362e6b7e50bfa5f30bdd6882c16ce8b165e3dc3 100644 (file)
               ></my-user-moderation-dropdown>
 
               <span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="badge badge-danger" i18n>Banned</span>
-              <span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span>
-              <span *ngIf="account.mutedServerByUser" class="badge badge-danger" i18n>Instance muted</span>
-              <span *ngIf="account.mutedByInstance" class="badge badge-danger" i18n>Muted by your instance</span>
-              <span *ngIf="account.mutedServerByInstance" class="badge badge-danger" i18n>Instance muted by your instance</span>
+
+              <my-account-block-badges [account]="account"></my-account-block-badges>
             </div>
 
             <div class="actor-handle">
index cdd00487b2706add34fdbb2681e6a9ca97239cdb..5043b98c40a192b0a563d11f289bca9d8d8ca603 100644 (file)
   }
 }
 
-my-user-moderation-dropdown,
-.badge {
-  @include margin-left(10px);
+my-user-moderation-dropdown {
+  margin: 0 10px;
 
-  position: relative;
-  top: 3px;
-}
-
-.badge {
-  font-size: 13px;
+  height: fit-content;
 }
 
 .copy-button {
@@ -64,6 +58,10 @@ my-user-moderation-dropdown,
   @include avatar-row-responsive(var(--myImgMargin), var(--myGreyFontSize));
 }
 
+.actor-display-name {
+  align-items: center;
+}
+
 .description {
   grid-column: 1 / 3;
   max-width: 1000px;
index 0dcbc250ab14df9e719e0b6b3d1453e4cf6a480d..8983254928339990c8f76a43073809501580f636 100644 (file)
@@ -12,7 +12,7 @@ import {
   VideoChannelService,
   VideoService
 } from '@app/shared/shared-main'
-import { AccountReportComponent } from '@app/shared/shared-moderation'
+import { AccountReportComponent, BlocklistService } from '@app/shared/shared-moderation'
 import { HttpStatusCode, User, UserRight } from '@shared/models'
 
 @Component({
@@ -52,6 +52,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
     private authService: AuthService,
     private videoService: VideoService,
     private markdown: MarkdownService,
+    private blocklist: BlocklistService,
     private screenService: ScreenService
   ) {
   }
@@ -159,6 +160,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
     this.updateModerationActions()
     this.loadUserIfNeeded(account)
     this.loadAccountVideosCount()
+    this.loadAccountBlockStatus()
   }
 
   private showReportModal () {
@@ -217,4 +219,9 @@ export class AccountsComponent implements OnInit, OnDestroy {
       this.accountVideosCount = res.total
     })
   }
+
+  private loadAccountBlockStatus () {
+    this.blocklist.getStatus({ accounts: [ this.account.nameWithHostForced ], hosts: [ this.account.host ] })
+      .subscribe(status => this.account.updateBlockStatus(status))
+  }
 }
index 318c8e2c28847d73b755e3d1b7ade4e13bef8268..c9533208a14fb73a559c2feac0e2eeed7b48feed 100644 (file)
         </ng-container>
       </div>
 
+      <ng-container formGroupName="client">
+
+        <ng-container formGroupName="videos">
+          <ng-container formGroupName="miniature">
+            <div class="form-group">
+              <my-peertube-checkbox
+                inputName="clientVideosMiniaturePreferAuthorDisplayName" formControlName="preferAuthorDisplayName"
+                i18n-labelText labelText="Prefer author display name in video miniature"
+              ></my-peertube-checkbox>
+            </div>
+          </ng-container>
+        </ng-container>
+
+        <ng-container formGroupName="menu">
+          <ng-container formGroupName="login">
+            <div class="form-group">
+              <my-peertube-checkbox
+                inputName="clientMenuLoginRedirectOnSingleExternalAuth" formControlName="redirectOnSingleExternalAuth"
+                i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu"
+              >
+                <ng-container ngProjectAs="description">
+                  <span *ngIf="countExternalAuth() === 0" i18n>⚠️ You don't have any external auth plugin enabled.</span>
+                  <span *ngIf="countExternalAuth() > 1" i18n>⚠️ You have multiple external auth plugins enabled.</span>
+                </ng-container>
+             </my-peertube-checkbox>
+            </div>
+          </ng-container>
+        </ng-container>
+      </ng-container>
+
     </div>
   </div>
 
     <div class="form-group col-12 col-lg-4 col-xl-3">
       <div i18n class="inner-form-title">VIDEO CHANNELS</div>
     </div>
-    
+
     <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
       <div class="form-group" formGroupName="videoChannels">
         <label i18n for="videoChannelsMaxPerUser">Max video channels per user</label>
index 7a8258820cfcc635e2a0b7c8ae24d16d470358c8..81457bd3672a2e191af606f51c7744d3cf1de6ee 100644 (file)
@@ -36,6 +36,10 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
     }
   }
 
+  countExternalAuth () {
+    return this.serverConfig.plugin.registeredExternalAuths.length
+  }
+
   getVideoQuotaOptions () {
     return this.configService.videoQuotaOptions
   }
index fdb0a753270bce4c578bfdb523a1582c21b9c568..f2eaa30339f08bb903792cf2825f6e47e7033232 100644 (file)
@@ -106,6 +106,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
           whitelisted: null
         }
       },
+      client: {
+        videos: {
+          miniature: {
+            preferAuthorDisplayName: null
+          }
+        },
+        menu: {
+          login: {
+            redirectOnSingleExternalAuth: null
+          }
+        }
+      },
       cache: {
         previews: {
           size: CACHE_PREVIEWS_SIZE_VALIDATOR
index 90eea1505e4959a7a0d1c6c8752bb9c3a423ba21..531b06dc9216b4fe0c08a98efbbfb5dc7e987491 100644 (file)
@@ -48,7 +48,8 @@
           <input type="submit" class="peertube-button orange-button" i18n-value value="Login" [disabled]="!form.valid">
 
           <div class="additionnal-links">
-            <a i18n class="forgot-password-button" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a>
+            <a i18n role="button" class="forgot-password-button" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a>
+
             <div *ngIf="signupAllowed" class="signup-link">
               <span>·</span>
               <a i18n routerLink="/signup" class="create-an-account">Create an account</a>
index 1fa4bd3b5f4092b08e6ad106b5a7e9d937ec8a82..648b8db36a8608b59f910f29efb52f68214e8b40 100644 (file)
@@ -1,4 +1,4 @@
-import { environment } from 'src/environments/environment'
+
 import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
 import { AuthService, Notifier, RedirectService, UserService } from '@app/core'
@@ -7,6 +7,7 @@ import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/
 import { FormReactive, FormValidatorService } 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'
 
 @Component({
@@ -98,7 +99,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
   }
 
   getAuthHref (auth: RegisteredExternalAuthConfig) {
-    return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
+    return PluginsManager.getExternalAuthHref(auth)
   }
 
   login () {
index 064fbb6f5b6e30a60319aa622051abe279620090..aec2e373c246a5e69bccc503a16dcab3933d18c3 100644 (file)
         <div class="section-label" i18n>OWNER ACCOUNT</div>
 
         <div class="avatar-row">
-          <my-actor-avatar class="account-avatar" [account]="videoChannel.ownerAccount" [internalHref]="getAccountUrl()"></my-actor-avatar>
+          <my-actor-avatar class="account-avatar" [account]="ownerAccount" [internalHref]="getAccountUrl()"></my-actor-avatar>
 
           <div class="actor-info">
             <h4>
-              <a [routerLink]="getAccountUrl()" title="View account" i18n-title>{{ videoChannel.ownerAccount.displayName }}</a>
+              <a [routerLink]="getAccountUrl()" title="View account" i18n-title>{{ ownerAccount.displayName }}</a>
             </h4>
 
             <div class="actor-handle">@{{ videoChannel.ownerBy }}</div>
+
+            <my-account-block-badges [account]="ownerAccount"></my-account-block-badges>
           </div>
         </div>
 
index 272fc41d95ef08e5155cb6d6281a7d243c012242..ebb991f4ee3a4fd5f63e175b12efc5fc0f5681f5 100644 (file)
@@ -4,7 +4,8 @@ import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators
 import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
 import { AuthService, MarkdownService, Notifier, RestExtractor, ScreenService } from '@app/core'
-import { ListOverflowItem, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { Account, ListOverflowItem, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { BlocklistService } from '@app/shared/shared-moderation'
 import { SupportModalComponent } from '@app/shared/shared-support-modal'
 import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
 import { HttpStatusCode } from '@shared/models'
@@ -18,6 +19,7 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
   @ViewChild('supportModal') supportModal: SupportModalComponent
 
   videoChannel: VideoChannel
+  ownerAccount: Account
   hotkeys: Hotkey[]
   links: ListOverflowItem[] = []
   isChannelManageable = false
@@ -38,7 +40,8 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
     private restExtractor: RestExtractor,
     private hotkeysService: HotkeysService,
     private screenService: ScreenService,
-    private markdown: MarkdownService
+    private markdown: MarkdownService,
+    private blocklist: BlocklistService
   ) { }
 
   ngOnInit () {
@@ -58,8 +61,10 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
 
                           // After the markdown renderer to avoid layout changes
                           this.videoChannel = videoChannel
+                          this.ownerAccount = new Account(this.videoChannel.ownerAccount)
 
                           this.loadChannelVideosCount()
+                          this.loadOwnerBlockStatus()
                         })
 
     this.hotkeys = [
@@ -125,4 +130,9 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
       sort: '-publishedAt'
     }).subscribe(res => this.channelVideosCount = res.total)
   }
+
+  private loadOwnerBlockStatus () {
+    this.blocklist.getStatus({ accounts: [ this.ownerAccount.nameWithHostForced ], hosts: [ this.ownerAccount.host ] })
+      .subscribe(status => this.ownerAccount.updateBlockStatus(status))
+  }
 }
index 35c39cc2ec12723637f1fc3924b2e0acbcedd8c3..76aaecf83b075c2f5228bb241392ad2020b3bd71 100644 (file)
@@ -2,15 +2,16 @@ import { NgModule } from '@angular/core'
 import { SharedFormModule } from '@app/shared/shared-forms'
 import { SharedGlobalIconModule } from '@app/shared/shared-icons'
 import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedModerationModule } from '@app/shared/shared-moderation'
 import { SharedSupportModal } from '@app/shared/shared-support-modal'
 import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
 import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
 import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
+import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
 import { VideoChannelPlaylistsComponent } from './video-channel-playlists/video-channel-playlists.component'
 import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
 import { VideoChannelsRoutingModule } from './video-channels-routing.module'
 import { VideoChannelsComponent } from './video-channels.component'
-import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
 
 @NgModule({
   imports: [
@@ -23,7 +24,8 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto
     SharedUserSubscriptionModule,
     SharedGlobalIconModule,
     SharedSupportModal,
-    SharedActorImageModule
+    SharedActorImageModule,
+    SharedModerationModule
   ],
 
   declarations: [
index 46dd807ec5b171eba79a819907e42e4b401a6971..9ea991042971021e8e1be60963f4bc7bd8269415 100644 (file)
 
               <div class="dropdown-divider"></div>
 
-              <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" (click)="openLanguageChooser()">
+              <a
+                myPluginSelector pluginSelectorId="menu-user-dropdown-language-item"
+                ngbDropdownItem ngbDropdownToggle class="dropdown-item" (click)="openLanguageChooser()"
+              >
                 <my-global-icon iconName="language" aria-hidden="true"></my-global-icon>
                 <span i18n>Interface:</span>
                 <span class="ml-auto text-muted">{{ currentInterfaceLanguage }}</span>
@@ -96,7 +99,9 @@
       </div>
 
       <div *ngIf="!isLoggedIn" class="login-buttons-block">
-        <a i18n routerLink="/login" class="peertube-button-link orange-button">Login</a>
+        <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>
       </div>
 
index 97f07c956edcb8d612f05dfa604cd06150ba802c..bcc88487856d9aff1877b2bbcd9bdeb2e992e095 100644 (file)
@@ -21,6 +21,7 @@ import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
 import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
 import { PeertubeModalService } from '@app/shared/shared-main/peertube-modal/peertube-modal.service'
 import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
+import { PluginsManager } from '@root-helpers/plugins-manager'
 import { HTMLServerConfig, ServerConfig, UserRight, VideoConstant } from '@shared/models'
 
 const logger = debug('peertube:menu:MenuComponent')
@@ -129,6 +130,15 @@ export class MenuComponent implements OnInit {
       .subscribe(() => this.openQuickSettings())
   }
 
+  getExternalLoginHref () {
+    if (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])
+  }
+
   isRegistrationAllowed () {
     if (!this.serverConfig) return false
 
index 92606e7fa9810c382253538e187767dee8731f6e..8b78d01a67c6aeecc7f1c916c76a793ef5f8cd1d 100644 (file)
@@ -1,4 +1,4 @@
-import { Account as ServerAccount, ActorImage } from '@shared/models'
+import { Account as ServerAccount, ActorImage, BlockStatus } from '@shared/models'
 import { Actor } from './actor.model'
 
 export class Account extends Actor implements ServerAccount {
@@ -49,4 +49,11 @@ export class Account extends Actor implements ServerAccount {
   resetAvatar () {
     this.avatar = null
   }
+
+  updateBlockStatus (blockStatus: BlockStatus) {
+    this.mutedByInstance = blockStatus.accounts[this.nameWithHostForced].blockedByServer
+    this.mutedByUser = blockStatus.accounts[this.nameWithHostForced].blockedByUser
+    this.mutedServerByUser = blockStatus.hosts[this.host].blockedByUser
+    this.mutedServerByInstance = blockStatus.hosts[this.host].blockedByServer
+  }
 }
diff --git a/client/src/app/shared/shared-moderation/account-block-badges.component.html b/client/src/app/shared/shared-moderation/account-block-badges.component.html
new file mode 100644 (file)
index 0000000..feac707
--- /dev/null
@@ -0,0 +1,4 @@
+<span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span>
+<span *ngIf="account.mutedServerByUser" class="badge badge-danger" i18n>Instance muted</span>
+<span *ngIf="account.mutedByInstance" class="badge badge-danger" i18n>Muted by your instance</span>
+<span *ngIf="account.mutedServerByInstance" class="badge badge-danger" i18n>Instance muted by your instance</span>
diff --git a/client/src/app/shared/shared-moderation/account-block-badges.component.scss b/client/src/app/shared/shared-moderation/account-block-badges.component.scss
new file mode 100644 (file)
index 0000000..ccc3666
--- /dev/null
@@ -0,0 +1,9 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
+.badge {
+  @include margin-right(10px);
+
+  height: fit-content;
+  font-size: 12px;
+}
diff --git a/client/src/app/shared/shared-moderation/account-block-badges.component.ts b/client/src/app/shared/shared-moderation/account-block-badges.component.ts
new file mode 100644 (file)
index 0000000..a726011
--- /dev/null
@@ -0,0 +1,11 @@
+import { Component, Input } from '@angular/core'
+import { Account } from '../shared-main'
+
+@Component({
+  selector: 'my-account-block-badges',
+  styleUrls: [ './account-block-badges.component.scss' ],
+  templateUrl: './account-block-badges.component.html'
+})
+export class AccountBlockBadgesComponent {
+  @Input() account: Account
+}
index db2a8c5846ea920cfda02045344f5c6f8cd6aa3b..f4836c6c4bd694893ea29cc66163ae8207f1b4cb 100644 (file)
@@ -3,7 +3,7 @@ import { catchError, map } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { RestExtractor, RestPagination, RestService } from '@app/core'
-import { AccountBlock as AccountBlockServer, ResultList, ServerBlock } from '@shared/models'
+import { AccountBlock as AccountBlockServer, BlockStatus, ResultList, ServerBlock } from '@shared/models'
 import { environment } from '../../../environments/environment'
 import { Account } from '../shared-main'
 import { AccountBlock } from './account-block.model'
@@ -12,6 +12,7 @@ export enum BlocklistComponentType { Account, Instance }
 
 @Injectable()
 export class BlocklistService {
+  static BASE_BLOCKLIST_URL = environment.apiUrl + '/api/v1/blocklist'
   static BASE_USER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/users/me/blocklist'
   static BASE_SERVER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/server/blocklist'
 
@@ -21,6 +22,23 @@ export class BlocklistService {
     private restService: RestService
   ) { }
 
+  /** ********************* Blocklist status ***********************/
+
+  getStatus (options: {
+    accounts?: string[]
+    hosts?: string[]
+  }) {
+    const { accounts, hosts } = options
+
+    let params = new HttpParams()
+
+    if (accounts) params = this.restService.addArrayParams(params, 'accounts', accounts)
+    if (hosts) params = this.restService.addArrayParams(params, 'hosts', hosts)
+
+    return this.authHttp.get<BlockStatus>(BlocklistService.BASE_BLOCKLIST_URL + '/status', { params })
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
   /** ********************* User -> Account blocklist ***********************/
 
   getUserAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
index 41c910ffe5c4cae67e54c581d93589db3f8b5051..da85b2299c5ac1c9d6bdd1697540c7d33dd29a85 100644 (file)
@@ -1,6 +1,7 @@
 export * from './report-modals'
 
 export * from './abuse.service'
+export * from './account-block-badges.component'
 export * from './account-block.model'
 export * from './account-blocklist.component'
 export * from './batch-domains-modal.component'
index 95213e2bd969376308dadbe607d80906fc8ff53f..7cadda67c83fc72932a9de3be6513587086cb00c 100644 (file)
@@ -13,6 +13,7 @@ import { UserBanModalComponent } from './user-ban-modal.component'
 import { UserModerationDropdownComponent } from './user-moderation-dropdown.component'
 import { VideoBlockComponent } from './video-block.component'
 import { VideoBlockService } from './video-block.service'
+import { AccountBlockBadgesComponent } from './account-block-badges.component'
 import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
 
 @NgModule({
@@ -31,7 +32,8 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image
     VideoReportComponent,
     BatchDomainsModalComponent,
     CommentReportComponent,
-    AccountReportComponent
+    AccountReportComponent,
+    AccountBlockBadgesComponent
   ],
 
   exports: [
@@ -41,7 +43,8 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image
     VideoReportComponent,
     BatchDomainsModalComponent,
     CommentReportComponent,
-    AccountReportComponent
+    AccountReportComponent,
+    AccountBlockBadgesComponent
   ],
 
   providers: [
index b18d861d69e06d5c72776fcdcb6ec2806b190175..e2cd2cdc10255f98fd6841640453d7ae5fd38342 100644 (file)
@@ -289,13 +289,13 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
       {
         label: $localize`Mute the instance`,
         description: $localize`Hide any content from that instance for you.`,
-        isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false,
+        isDisplayed: ({ account }) => !account.userId && account.mutedServerByUser === false,
         handler: ({ account }) => this.blockServerByUser(account.host)
       },
       {
         label: $localize`Unmute the instance`,
         description: $localize`Show back content from that instance for you.`,
-        isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true,
+        isDisplayed: ({ account }) => !account.userId && account.mutedServerByUser === true,
         handler: ({ account }) => this.unblockServerByUser(account.host)
       },
       {
index 71c31696a3e5f70b4f896dc9f3b23cc7d9583010..421ce4934d2a11016aef55599318169f5bdb1009 100644 (file)
@@ -146,7 +146,10 @@ class Html5Hlsjs {
   }
 
   duration () {
-    return this._duration || this.videoElement.duration || 0
+    if (this._duration === Infinity) return Infinity
+    if (!isNaN(this.videoElement.duration)) return this.videoElement.duration
+
+    return this._duration || 0
   }
 
   seekable () {
@@ -366,6 +369,7 @@ class Html5Hlsjs {
 
       this.isLive = data.details.live
       this.dvrDuration = data.details.totalduration
+
       this._duration = this.isLive ? Infinity : data.details.totalduration
     })
 
index a1b763ff2336e95d021dbc639a3781b082e04b96..9cba633738254321066562c6c314dab8ec3b87da 100644 (file)
@@ -15,6 +15,7 @@ import {
   RegisterClientHookOptions,
   RegisterClientSettingsScript,
   RegisterClientVideoFieldOptions,
+  RegisteredExternalAuthConfig,
   ServerConfigPlugin
 } from '../../../shared/models'
 import { environment } from '../environments/environment'
@@ -78,6 +79,11 @@ class PluginsManager {
     return isTheme ? '/themes' : '/plugins'
   }
 
+  static getExternalAuthHref (auth: RegisteredExternalAuthConfig) {
+    return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
+
+  }
+
   loadPluginsList (config: HTMLServerConfig) {
     for (const plugin of config.plugin.registered) {
       this.addPlugin(plugin)
index b9c725cea1eb846f2bc91645ad54e3e9c431c00e..0749511174d87ffb2dec02546782e6aacd08a88a 100644 (file)
@@ -82,6 +82,12 @@ client:
       # By default PeerTube client displays author username
       prefer_author_display_name: false
 
+  menu:
+    login:
+      # If you enable only one external auth plugin
+      # You can automatically redirect your users on this external platform when they click on the login button
+      redirect_on_single_external_auth: false
+
 # From the project root directory
 storage:
   tmp: 'storage/tmp/' # Use to download data (imports etc), store uploaded files before and during processing...
index d67349c1dcb2e4fb9a152e922dd4c55a45db5cce..e38b79587c3813b577ec271bc5d326ab111cd922 100644 (file)
@@ -80,6 +80,12 @@ client:
       # By default PeerTube client displays author username
       prefer_author_display_name: false
 
+  menu:
+    login:
+      # If you enable only one external auth plugin
+      # You can automatically redirect your users on this external platform when they click on the login button
+      redirect_on_single_external_auth: false
+
 # From the project root directory
 storage:
   tmp: '/var/www/peertube/storage/tmp/' # Use to download data (imports etc), store uploaded files before and during processing...
index 2e7f982d3ca01204da1cd58a3ab73b21174cff2c..461e1b4ba637e88b28a3330d396f088aed9ff647 100644 (file)
@@ -155,6 +155,7 @@ search:
 federation:
   videos:
     federate_unlisted: true
+    cleanup_remote_interactions: false
 
 views:
   videos:
index 6cc00d1ae34e6b702428b821c78f78eadec6cdc4..b03f5dcdb01b1702891caa2f2d3283ad39b0505c 100644 (file)
@@ -90,7 +90,6 @@
     "cookie-parser": "^1.4.3",
     "cors": "^2.8.1",
     "create-torrent": "^5.0.0",
-    "decache": "^4.6.0",
     "deep-object-diff": "^1.1.0",
     "email-templates": "^8.0.3",
     "execa": "^5.1.1",
diff --git a/server/controllers/api/blocklist.ts b/server/controllers/api/blocklist.ts
new file mode 100644 (file)
index 0000000..1e936ad
--- /dev/null
@@ -0,0 +1,108 @@
+import express from 'express'
+import { handleToNameAndHost } from '@server/helpers/actors'
+import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
+import { getServerActor } from '@server/models/application/application'
+import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
+import { MActorAccountId, MUserAccountId } from '@server/types/models'
+import { BlockStatus } from '@shared/models'
+import { asyncMiddleware, blocklistStatusValidator, optionalAuthenticate } from '../../middlewares'
+import { logger } from '@server/helpers/logger'
+
+const blocklistRouter = express.Router()
+
+blocklistRouter.get('/status',
+  optionalAuthenticate,
+  blocklistStatusValidator,
+  asyncMiddleware(getBlocklistStatus)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  blocklistRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function getBlocklistStatus (req: express.Request, res: express.Response) {
+  const hosts = req.query.hosts as string[]
+  const accounts = req.query.accounts as string[]
+  const user = res.locals.oauth?.token.User
+
+  const serverActor = await getServerActor()
+
+  const byAccountIds = [ serverActor.Account.id ]
+  if (user) byAccountIds.push(user.Account.id)
+
+  const status: BlockStatus = {
+    accounts: {},
+    hosts: {}
+  }
+
+  const baseOptions = {
+    byAccountIds,
+    user,
+    serverActor,
+    status
+  }
+
+  await Promise.all([
+    populateServerBlocklistStatus({ ...baseOptions, hosts }),
+    populateAccountBlocklistStatus({ ...baseOptions, accounts })
+  ])
+
+  return res.json(status)
+}
+
+async function populateServerBlocklistStatus (options: {
+  byAccountIds: number[]
+  user?: MUserAccountId
+  serverActor: MActorAccountId
+  hosts: string[]
+  status: BlockStatus
+}) {
+  const { byAccountIds, user, serverActor, hosts, status } = options
+
+  if (!hosts || hosts.length === 0) return
+
+  const serverBlocklistStatus = await ServerBlocklistModel.getBlockStatus(byAccountIds, hosts)
+
+  logger.debug('Got server blocklist status.', { serverBlocklistStatus, byAccountIds, hosts })
+
+  for (const host of hosts) {
+    const block = serverBlocklistStatus.find(b => b.host === host)
+
+    status.hosts[host] = getStatus(block, serverActor, user)
+  }
+}
+
+async function populateAccountBlocklistStatus (options: {
+  byAccountIds: number[]
+  user?: MUserAccountId
+  serverActor: MActorAccountId
+  accounts: string[]
+  status: BlockStatus
+}) {
+  const { byAccountIds, user, serverActor, accounts, status } = options
+
+  if (!accounts || accounts.length === 0) return
+
+  const accountBlocklistStatus = await AccountBlocklistModel.getBlockStatus(byAccountIds, accounts)
+
+  logger.debug('Got account blocklist status.', { accountBlocklistStatus, byAccountIds, accounts })
+
+  for (const account of accounts) {
+    const sanitizedHandle = handleToNameAndHost(account)
+
+    const block = accountBlocklistStatus.find(b => b.name === sanitizedHandle.name && b.host === sanitizedHandle.host)
+
+    status.accounts[sanitizedHandle.handle] = getStatus(block, serverActor, user)
+  }
+}
+
+function getStatus (block: { accountId: number }, serverActor: MActorAccountId, user?: MUserAccountId) {
+  return {
+    blockedByServer: !!(block && block.accountId === serverActor.Account.id),
+    blockedByUser: !!(block && user && block.accountId === user.Account.id)
+  }
+}
index 805ad99c7e0d48ddad6fec74f17ea0e440585cb3..b253db397056e510b1ae889af589f0ecaee44965 100644 (file)
@@ -169,6 +169,18 @@ function customConfig (): CustomConfig {
         whitelisted: CONFIG.SERVICES.TWITTER.WHITELISTED
       }
     },
+    client: {
+      videos: {
+        miniature: {
+          preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
+        }
+      },
+      menu: {
+        login: {
+          redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH
+        }
+      }
+    },
     cache: {
       previews: {
         size: CONFIG.CACHE.PREVIEWS.SIZE
index 9949b378a041454b5887d89afe089b921b7120ef..5f49336b13c886ad39820c0adc98ee2cef461b94 100644 (file)
@@ -6,6 +6,7 @@ import { badRequest } from '../../helpers/express-utils'
 import { CONFIG } from '../../initializers/config'
 import { abuseRouter } from './abuse'
 import { accountsRouter } from './accounts'
+import { blocklistRouter } from './blocklist'
 import { bulkRouter } from './bulk'
 import { configRouter } from './config'
 import { customPageRouter } from './custom-page'
@@ -49,6 +50,7 @@ apiRouter.use('/search', searchRouter)
 apiRouter.use('/overviews', overviewsRouter)
 apiRouter.use('/plugins', pluginRouter)
 apiRouter.use('/custom-pages', customPageRouter)
+apiRouter.use('/blocklist', blocklistRouter)
 apiRouter.use('/ping', pong)
 apiRouter.use('/*', badRequest)
 
index 2de7fe41f48e826cea5e9a8d729021a45faee500..de9e055dc0fe4b9bf7db4b7c0a5484237a44ebc9 100644 (file)
@@ -144,8 +144,13 @@ async function installPlugin (req: express.Request, res: express.Response) {
 
   const fromDisk = !!body.path
   const toInstall = body.npmName || body.path
+
+  const pluginVersion = body.pluginVersion && body.npmName
+    ? body.pluginVersion
+    : undefined
+
   try {
-    const plugin = await PluginManager.Instance.install(toInstall, undefined, fromDisk)
+    const plugin = await PluginManager.Instance.install(toInstall, pluginVersion, fromDisk)
 
     return res.json(plugin.toFormattedJSON())
   } catch (err) {
index 6799ca8c510bccc9d3ea7cf6405a78af065509f1..fb1f6863513441b862a5e1bac1f2a2ad0d8b61a2 100644 (file)
@@ -1,5 +1,6 @@
 import 'multer'
 import express from 'express'
+import { handlesToNameAndHost } from '@server/helpers/actors'
 import { pickCommonVideoQuery } from '@server/helpers/query'
 import { sendUndoFollow } from '@server/lib/activitypub/send'
 import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils'
@@ -7,7 +8,6 @@ import { VideoChannelModel } from '@server/models/video/video-channel'
 import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
 import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils'
 import { getFormattedObjects } from '../../../helpers/utils'
-import { WEBSERVER } from '../../../initializers/constants'
 import { sequelizeTypescript } from '../../../initializers/database'
 import { JobQueue } from '../../../lib/job-queue'
 import {
@@ -89,28 +89,23 @@ async function areSubscriptionsExist (req: express.Request, res: express.Respons
   const uris = req.query.uris as string[]
   const user = res.locals.oauth.token.User
 
-  const handles = uris.map(u => {
-    let [ name, host ] = u.split('@')
-    if (host === WEBSERVER.HOST) host = null
+  const sanitizedHandles = handlesToNameAndHost(uris)
 
-    return { name, host, uri: u }
-  })
-
-  const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, handles)
+  const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, sanitizedHandles)
 
   const existObject: { [id: string ]: boolean } = {}
-  for (const handle of handles) {
+  for (const sanitizedHandle of sanitizedHandles) {
     const obj = results.find(r => {
       const server = r.ActorFollowing.Server
 
-      return r.ActorFollowing.preferredUsername === handle.name &&
+      return r.ActorFollowing.preferredUsername === sanitizedHandle.name &&
         (
-          (!server && !handle.host) ||
-          (server.host === handle.host)
+          (!server && !sanitizedHandle.host) ||
+          (server.host === sanitizedHandle.host)
         )
     })
 
-    existObject[handle.uri] = obj !== undefined
+    existObject[sanitizedHandle.handle] = obj !== undefined
   }
 
   return res.json(existObject)
index 6773b500f4e0981ddc56a74cf9ea1d497a79ed39..c827f6bf0d161bc0a693d032d1acc5068bce4182 100644 (file)
@@ -8,6 +8,7 @@ import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
 import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
 import { generateWebTorrentVideoFilename } from '@server/lib/paths'
 import { Redis } from '@server/lib/redis'
+import { uploadx } from '@server/lib/uploadx'
 import {
   addMoveToObjectStorageJob,
   addOptimizeOrMergeAudioJob,
@@ -19,7 +20,6 @@ import { VideoPathManager } from '@server/lib/video-path-manager'
 import { buildNextVideoState } from '@server/lib/video-state'
 import { openapiOperationDoc } from '@server/middlewares/doc'
 import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
-import { Uploadx } from '@uploadx/core'
 import { VideoCreate, VideoState } from '../../../../shared'
 import { HttpStatusCode } from '../../../../shared/models'
 import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
@@ -41,8 +41,8 @@ import {
   authenticate,
   videosAddLegacyValidator,
   videosAddResumableInitValidator,
-  videosResumableUploadIdValidator,
-  videosAddResumableValidator
+  videosAddResumableValidator,
+  videosResumableUploadIdValidator
 } from '../../../middlewares'
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
 import { VideoModel } from '../../../models/video/video'
@@ -52,9 +52,6 @@ const lTags = loggerTagsFactory('api', 'video')
 const auditLogger = auditLoggerFactory('videos')
 const uploadRouter = express.Router()
 
-const uploadx = new Uploadx({ directory: getResumableUploadPath() })
-uploadx.getUserId = (_, res: express.Response) => res.locals.oauth?.token.user.id
-
 const reqVideoFileAdd = createReqFiles(
   [ 'videofile', 'thumbnailfile', 'previewfile' ],
   Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
diff --git a/server/helpers/actors.ts b/server/helpers/actors.ts
new file mode 100644 (file)
index 0000000..c31fe6f
--- /dev/null
@@ -0,0 +1,17 @@
+import { WEBSERVER } from '@server/initializers/constants'
+
+function handleToNameAndHost (handle: string) {
+  let [ name, host ] = handle.split('@')
+  if (host === WEBSERVER.HOST) host = null
+
+  return { name, host, handle }
+}
+
+function handlesToNameAndHost (handles: string[]) {
+  return handles.map(h => handleToNameAndHost(h))
+}
+
+export {
+  handleToNameAndHost,
+  handlesToNameAndHost
+}
diff --git a/server/helpers/decache.ts b/server/helpers/decache.ts
new file mode 100644 (file)
index 0000000..e31973b
--- /dev/null
@@ -0,0 +1,78 @@
+// Thanks: https://github.com/dwyl/decache
+// We reuse this file to also uncache plugin base path
+
+import { extname } from 'path'
+
+function decachePlugin (pluginPath: string, libraryPath: string) {
+  const moduleName = find(libraryPath)
+
+  if (!moduleName) return
+
+  searchCache(moduleName, function (mod) {
+    delete require.cache[mod.id]
+  })
+
+  removeCachedPath(pluginPath)
+}
+
+function decacheModule (name: string) {
+  const moduleName = find(name)
+
+  if (!moduleName) return
+
+  searchCache(moduleName, function (mod) {
+    delete require.cache[mod.id]
+  })
+
+  removeCachedPath(moduleName)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  decacheModule,
+  decachePlugin
+}
+
+// ---------------------------------------------------------------------------
+
+function find (moduleName: string) {
+  try {
+    return require.resolve(moduleName)
+  } catch {
+    return ''
+  }
+}
+
+function searchCache (moduleName: string, callback: (current: NodeModule) => void) {
+  const resolvedModule = require.resolve(moduleName)
+  let mod: NodeModule
+  const visited = {}
+
+  if (resolvedModule && ((mod = require.cache[resolvedModule]) !== undefined)) {
+    // Recursively go over the results
+    (function run (current) {
+      visited[current.id] = true
+
+      current.children.forEach(function (child) {
+        if (extname(child.filename) !== '.node' && !visited[child.id]) {
+          run(child)
+        }
+      })
+
+      // Call the specified callback providing the
+      // found module
+      callback(current)
+    })(mod)
+  }
+};
+
+function removeCachedPath (pluginPath: string) {
+  const pathCache = (module.constructor as any)._pathCache
+
+  Object.keys(pathCache).forEach(function (cacheKey) {
+    if (cacheKey.includes(pluginPath)) {
+      delete pathCache[cacheKey]
+    }
+  })
+}
index 51c39654839709f5efa7d86a77047775e1818b49..c85c389cd98430da954f8cb564e1d839e951fceb 100644 (file)
@@ -33,6 +33,7 @@ function checkMissedConfig () {
     'transcoding.resolutions.2160p',
     'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled',
     'trending.videos.interval_days',
+    'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth',
     'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
     'instance.is_nsfw', 'instance.default_nsfw_policy', 'instance.robots', 'instance.securitytxt',
     'services.twitter.username', 'services.twitter.whitelisted',
index dadda2a774d52e3dc3bf2d312cda612b118517fa..eb848be6b9c5d0fd722b5474aa911caa22946aad 100644 (file)
@@ -1,7 +1,7 @@
 import bytes from 'bytes'
 import { IConfig } from 'config'
-import decache from 'decache'
 import { dirname, join } from 'path'
+import { decacheModule } from '@server/helpers/decache'
 import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
 import { BroadcastMessageLevel } from '@shared/models/server'
 import { VideosRedundancyStrategy } from '../../shared/models'
@@ -63,6 +63,11 @@ const CONFIG = {
       MINIATURE: {
         get PREFER_AUTHOR_DISPLAY_NAME () { return config.get<boolean>('client.videos.miniature.prefer_author_display_name') }
       }
+    },
+    MENU: {
+      LOGIN: {
+        get REDIRECT_ON_SINGLE_EXTERNAL_AUTH () { return config.get<boolean>('client.menu.login.redirect_on_single_external_auth') }
+      }
     }
   },
 
@@ -497,7 +502,7 @@ export function reloadConfig () {
       delete require.cache[fileName]
     }
 
-    decache('config')
+    decacheModule('config')
   }
 
   purge()
index b8633e83e1bddcadad24f71a685f9159f9fae980..c61c01d62e8e3b59ac3d1a17c3dcb5fa0b6bf859 100644 (file)
@@ -223,7 +223,7 @@ const SCHEDULER_INTERVALS_MS = {
   REMOVE_OLD_VIEWS: 60000 * 60 * 24, // 1 day
   REMOVE_OLD_HISTORY: 60000 * 60 * 24, // 1 day
   UPDATE_INBOX_STATS: 1000 * 60, // 1 minute
-  REMOVE_DANGLING_RESUMABLE_UPLOADS: 60000 * 60 * 16 // 16 hours
+  REMOVE_DANGLING_RESUMABLE_UPLOADS: 60000 * 60 // 1 hour
 }
 
 // ---------------------------------------------------------------------------
index d6b684015cb6e3f0fcf3563ae7c28b3ffb5be408..98273a6ea0de237842c3218eace53415a3df49ae 100644 (file)
@@ -40,12 +40,12 @@ async function isBlockedByServerOrAccount (targetAccount: MAccountServer, userAc
 
   if (userAccount) sourceAccounts.push(userAccount.id)
 
-  const accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(sourceAccounts, targetAccount.id)
+  const accountMutedHash = await AccountBlocklistModel.isAccountMutedByAccounts(sourceAccounts, targetAccount.id)
   if (accountMutedHash[serverAccountId] || (userAccount && accountMutedHash[userAccount.id])) {
     return true
   }
 
-  const instanceMutedHash = await ServerBlocklistModel.isServerMutedByMulti(sourceAccounts, targetAccount.Actor.serverId)
+  const instanceMutedHash = await ServerBlocklistModel.isServerMutedByAccounts(sourceAccounts, targetAccount.Actor.serverId)
   if (instanceMutedHash[serverAccountId] || (userAccount && instanceMutedHash[userAccount.id])) {
     return true
   }
index 360b4667f586bf36e9955ec184da60788b0199ca..adc3d712e98eda336e10e5f81a4b7cfbac98a9c4 100644 (file)
@@ -350,10 +350,6 @@ class ClientHtml {
     return join(__dirname, '../../../client/dist/standalone/videos/embed.html')
   }
 
-  private static addHtmlLang (htmlStringPage: string, paramLang: string) {
-    return htmlStringPage.replace('<html>', `<html lang="${paramLang}">`)
-  }
-
   private static addManifestContentHash (htmlStringPage: string) {
     return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
   }
index 4f84d8dea2a84c5ce2ee5338d874b7228cb53cce..765cbaad95fecff738cabd260eadaa4f5329c765 100644 (file)
@@ -47,8 +47,8 @@ export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MU
 
     const sourceAccounts = this.users.map(u => u.Account.id).concat([ this.serverAccountId ])
 
-    this.accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(sourceAccounts, this.payload.accountId)
-    this.instanceMutedHash = await ServerBlocklistModel.isServerMutedByMulti(sourceAccounts, this.payload.Account.Actor.serverId)
+    this.accountMutedHash = await AccountBlocklistModel.isAccountMutedByAccounts(sourceAccounts, this.payload.accountId)
+    this.instanceMutedHash = await ServerBlocklistModel.isServerMutedByAccounts(sourceAccounts, this.payload.Account.Actor.serverId)
   }
 
   log () {
index d4d2a7edc31cf9f4610912a5089295c7eb7e929c..6c2f4764e4169f28e826ca970e22bdc75c6131a1 100644 (file)
@@ -1,8 +1,8 @@
-import decache from 'decache'
 import express from 'express'
 import { createReadStream, createWriteStream } from 'fs'
 import { ensureDir, outputFile, readJSON } from 'fs-extra'
 import { basename, join } from 'path'
+import { decachePlugin } from '@server/helpers/decache'
 import { MOAuthTokenUser, MUser } from '@server/types/models'
 import { getCompleteLocale } from '@shared/core-utils'
 import { ClientScript, PluginPackageJson, PluginTranslation, PluginTranslationPaths, RegisterServerHookOptions } from '@shared/models'
@@ -420,7 +420,7 @@ export class PluginManager implements ServerHook {
 
     // Delete cache if needed
     const modulePath = join(pluginPath, packageJSON.library)
-    decache(modulePath)
+    decachePlugin(pluginPath, modulePath)
     const library: PluginLibrary = require(modulePath)
 
     if (!isLibraryCodeValid(library)) {
index d6e561cadffa54ec70e7835e00a5b377c9092064..61e93eafa89402d6508b3da15956ce7f2d210da4 100644 (file)
@@ -1,9 +1,7 @@
-import { map } from 'bluebird'
-import { readdir, remove, stat } from 'fs-extra'
+
 import { logger, loggerTagsFactory } from '@server/helpers/logger'
-import { getResumableUploadPath } from '@server/helpers/upload'
 import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants'
-import { METAFILE_EXTNAME } from '@uploadx/core'
+import { uploadx } from '../uploadx'
 import { AbstractScheduler } from './abstract-scheduler'
 
 const lTags = loggerTagsFactory('scheduler', 'resumable-upload', 'cleaner')
@@ -22,36 +20,17 @@ export class RemoveDanglingResumableUploadsScheduler extends AbstractScheduler {
   }
 
   protected async internalExecute () {
-    const path = getResumableUploadPath()
-    const files = await readdir(path)
-
-    const metafiles = files.filter(f => f.endsWith(METAFILE_EXTNAME))
+    logger.debug('Removing dangling resumable uploads', lTags())
 
-    if (metafiles.length === 0) return
-
-    logger.debug('Reading resumable video upload folder %s with %d files', path, metafiles.length, lTags())
+    const now = new Date().getTime()
 
     try {
-      await map(metafiles, metafile => {
-        return this.deleteIfOlderThan(metafile, this.lastExecutionTimeMs)
-      }, { concurrency: 5 })
+      // Remove files that were not updated since the last execution
+      await uploadx.storage.purge(now - this.lastExecutionTimeMs)
     } catch (error) {
       logger.error('Failed to handle file during resumable video upload folder cleanup', { error, ...lTags() })
     } finally {
-      this.lastExecutionTimeMs = new Date().getTime()
-    }
-  }
-
-  private async deleteIfOlderThan (metafile: string, olderThan: number) {
-    const metafilePath = getResumableUploadPath(metafile)
-    const statResult = await stat(metafilePath)
-
-    // Delete uploads that started since a long time
-    if (statResult.ctimeMs < olderThan) {
-      await remove(metafilePath)
-
-      const datafile = metafilePath.replace(new RegExp(`${METAFILE_EXTNAME}$`), '')
-      await remove(datafile)
+      this.lastExecutionTimeMs = now
     }
   }
 
index bdf6492f98b3b540bbd19371cc6c96466a81915a..6aa459f82dd9b5b2f075a7473fd62a356e4fd8c3 100644 (file)
@@ -47,6 +47,11 @@ class ServerConfigManager {
           miniature: {
             preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
           }
+        },
+        menu: {
+          login: {
+            redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH
+          }
         }
       },
 
diff --git a/server/lib/uploadx.ts b/server/lib/uploadx.ts
new file mode 100644 (file)
index 0000000..11b1044
--- /dev/null
@@ -0,0 +1,10 @@
+import express from 'express'
+import { getResumableUploadPath } from '@server/helpers/upload'
+import { Uploadx } from '@uploadx/core'
+
+const uploadx = new Uploadx({ directory: getResumableUploadPath() })
+uploadx.getUserId = (_, res: express.Response) => res.locals.oauth?.token.user.id
+
+export {
+  uploadx
+}
index b7749e204c8bac8bc6ba164a78876ddcc04bb36f..12980ced4d722818f7e9344c3cb2e34115a7555a 100644 (file)
@@ -1,8 +1,10 @@
 import express from 'express'
-import { body, param } from 'express-validator'
+import { body, param, query } from 'express-validator'
+import { areValidActorHandles } from '@server/helpers/custom-validators/activitypub/actor'
+import { toArray } from '@server/helpers/custom-validators/misc'
 import { getServerActor } from '@server/models/application/application'
 import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
-import { isHostValid } from '../../helpers/custom-validators/servers'
+import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers'
 import { logger } from '../../helpers/logger'
 import { WEBSERVER } from '../../initializers/constants'
 import { AccountBlocklistModel } from '../../models/account/account-blocklist'
@@ -123,6 +125,26 @@ const unblockServerByServerValidator = [
   }
 ]
 
+const blocklistStatusValidator = [
+  query('hosts')
+    .optional()
+    .customSanitizer(toArray)
+    .custom(isEachUniqueHostValid).withMessage('Should have a valid hosts array'),
+
+  query('accounts')
+    .optional()
+    .customSanitizer(toArray)
+    .custom(areValidActorHandles).withMessage('Should have a valid accounts array'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking blocklistStatusValidator parameters', { query: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -131,7 +153,8 @@ export {
   unblockAccountByAccountValidator,
   unblockServerByAccountValidator,
   unblockAccountByServerValidator,
-  unblockServerByServerValidator
+  unblockServerByServerValidator,
+  blocklistStatusValidator
 }
 
 // ---------------------------------------------------------------------------
index 21171af236e36cc2152a83b51f76cd7ec8c9f0d0..c1e9ebefbb25f8aa2af3a9d6bf642426ab2d24a1 100644 (file)
@@ -116,6 +116,9 @@ const installOrUpdatePluginValidator = [
   body('npmName')
     .optional()
     .custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'),
+  body('pluginVersion')
+    .optional()
+    .custom(isPluginVersionValid).withMessage('Should have a valid plugin version'),
   body('path')
     .optional()
     .custom(isSafePath).withMessage('Should have a valid safe path'),
@@ -129,6 +132,9 @@ const installOrUpdatePluginValidator = [
     if (!body.path && !body.npmName) {
       return res.fail({ message: 'Should have either a npmName or a path' })
     }
+    if (body.pluginVersion && !body.npmName) {
+      return res.fail({ message: 'Should have a npmName when specifying a pluginVersion' })
+    }
 
     return next()
   }
index b2375b00648ac8a79d2aa0e05750a64744c7321d..21983428a7111cc162e5516b6ff45adb90b4251d 100644 (file)
@@ -1,11 +1,12 @@
-import { Op } from 'sequelize'
+import { Op, QueryTypes } from 'sequelize'
 import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import { handlesToNameAndHost } from '@server/helpers/actors'
 import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models'
 import { AttributesOnly } from '@shared/core-utils'
 import { AccountBlock } from '../../../shared/models'
 import { ActorModel } from '../actor/actor'
 import { ServerModel } from '../server/server'
-import { getSort, searchAttribute } from '../utils'
+import { createSafeIn, getSort, searchAttribute } from '../utils'
 import { AccountModel } from './account'
 
 enum ScopeNames {
@@ -77,7 +78,7 @@ export class AccountBlocklistModel extends Model<Partial<AttributesOnly<AccountB
   })
   BlockedAccount: AccountModel
 
-  static isAccountMutedByMulti (accountIds: number[], targetAccountId: number) {
+  static isAccountMutedByAccounts (accountIds: number[], targetAccountId: number) {
     const query = {
       attributes: [ 'accountId', 'id' ],
       where: {
@@ -187,6 +188,39 @@ export class AccountBlocklistModel extends Model<Partial<AttributesOnly<AccountB
       .then(entries => entries.map(e => `${e.BlockedAccount.Actor.preferredUsername}@${e.BlockedAccount.Actor.Server.host}`))
   }
 
+  static getBlockStatus (byAccountIds: number[], handles: string[]): Promise<{ name: string, host: string, accountId: number }[]> {
+    const sanitizedHandles = handlesToNameAndHost(handles)
+
+    const localHandles = sanitizedHandles.filter(h => !h.host)
+                                         .map(h => h.name)
+
+    const remoteHandles = sanitizedHandles.filter(h => !!h.host)
+                                          .map(h => ([ h.name, h.host ]))
+
+    const handlesWhere: string[] = []
+
+    if (localHandles.length !== 0) {
+      handlesWhere.push(`("actor"."preferredUsername" IN (:localHandles) AND "server"."id" IS NULL)`)
+    }
+
+    if (remoteHandles.length !== 0) {
+      handlesWhere.push(`(("actor"."preferredUsername", "server"."host") IN (:remoteHandles))`)
+    }
+
+    const rawQuery = `SELECT "accountBlocklist"."accountId", "actor"."preferredUsername" AS "name", "server"."host" ` +
+      `FROM "accountBlocklist" ` +
+      `INNER JOIN "account" ON "account"."id" = "accountBlocklist"."targetAccountId" ` +
+      `INNER JOIN "actor" ON "actor"."id" = "account"."actorId" ` +
+      `LEFT JOIN "server" ON "server"."id" = "actor"."serverId" ` +
+      `WHERE "accountBlocklist"."accountId" IN (${createSafeIn(AccountBlocklistModel.sequelize, byAccountIds)}) ` +
+      `AND (${handlesWhere.join(' OR ')})`
+
+    return AccountBlocklistModel.sequelize.query(rawQuery, {
+      type: QueryTypes.SELECT as QueryTypes.SELECT,
+      replacements: { byAccountIds, localHandles, remoteHandles }
+    })
+  }
+
   toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock {
     return {
       byAccount: this.ByAccount.toFormattedJSON(),
index b3579d5896f41f1bf596436abb4a67e484d4d463..092998db33c6682ee1cbe0bdf17c0e18f4ad1b20 100644 (file)
@@ -1,10 +1,10 @@
-import { Op } from 'sequelize'
+import { Op, QueryTypes } from 'sequelize'
 import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
 import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
 import { AttributesOnly } from '@shared/core-utils'
 import { ServerBlock } from '@shared/models'
 import { AccountModel } from '../account/account'
-import { getSort, searchAttribute } from '../utils'
+import { createSafeIn, getSort, searchAttribute } from '../utils'
 import { ServerModel } from './server'
 
 enum ScopeNames {
@@ -76,7 +76,7 @@ export class ServerBlocklistModel extends Model<Partial<AttributesOnly<ServerBlo
   })
   BlockedServer: ServerModel
 
-  static isServerMutedByMulti (accountIds: number[], targetServerId: number) {
+  static isServerMutedByAccounts (accountIds: number[], targetServerId: number) {
     const query = {
       attributes: [ 'accountId', 'id' ],
       where: {
@@ -141,6 +141,19 @@ export class ServerBlocklistModel extends Model<Partial<AttributesOnly<ServerBlo
       .then(entries => entries.map(e => e.BlockedServer.host))
   }
 
+  static getBlockStatus (byAccountIds: number[], hosts: string[]): Promise<{ host: string, accountId: number }[]> {
+    const rawQuery = `SELECT "server"."host", "serverBlocklist"."accountId" ` +
+      `FROM "serverBlocklist" ` +
+      `INNER JOIN "server" ON "server"."id" = "serverBlocklist"."targetServerId" ` +
+      `WHERE "server"."host" IN (:hosts) ` +
+      `AND "serverBlocklist"."accountId" IN (${createSafeIn(ServerBlocklistModel.sequelize, byAccountIds)})`
+
+    return ServerBlocklistModel.sequelize.query(rawQuery, {
+      type: QueryTypes.SELECT as QueryTypes.SELECT,
+      replacements: { hosts }
+    })
+  }
+
   static listForApi (parameters: {
     start: number
     count: number
index 7d5fae5cfb917dd27e43ff66ffe041af4baeeb84..f72a892e2dc7baec1e990f5277e460291b6e6df9 100644 (file)
@@ -481,6 +481,78 @@ describe('Test blocklist API validators', function () {
     })
   })
 
+  describe('When getting blocklist status', function () {
+    const path = '/api/v1/blocklist/status'
+
+    it('Should fail with a bad token', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path,
+        token: 'false',
+        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should fail with a bad accounts field', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path,
+        query: {
+          accounts: 1
+        },
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+
+      await makeGetRequest({
+        url: server.url,
+        path,
+        query: {
+          accounts: [ 1 ]
+        },
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with a bad hosts field', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path,
+        query: {
+          hosts: 1
+        },
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+
+      await makeGetRequest({
+        url: server.url,
+        path,
+        query: {
+          hosts: [ 1 ]
+        },
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should succeed with the correct parameters', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path,
+        query: {},
+        expectedStatus: HttpStatusCode.OK_200
+      })
+
+      await makeGetRequest({
+        url: server.url,
+        path,
+        query: {
+          hosts: [ 'example.com' ],
+          accounts: [ 'john@example.com' ]
+        },
+        expectedStatus: HttpStatusCode.OK_200
+      })
+    })
+  })
+
   after(async function () {
     await cleanupTests(servers)
   })
index d0cd7722b7c48d8b8626af0164ee018bfee6077e..a6e87730a5c5ddfa19977db713395365dcbd3586 100644 (file)
@@ -54,6 +54,18 @@ describe('Test config API validators', function () {
         whitelisted: true
       }
     },
+    client: {
+      videos: {
+        miniature: {
+          preferAuthorDisplayName: false
+        }
+      },
+      menu: {
+        login: {
+          redirectOnSingleExternalAuth: false
+        }
+      }
+    },
     cache: {
       previews: {
         size: 2
index 33f84ecbc4d0d2c8b7141c27c9d2bb73f8f59872..2c436376c96682f3fa76d5042be8503fb78b0418 100644 (file)
@@ -30,7 +30,7 @@ describe('Test server plugins API validators', function () {
   // ---------------------------------------------------------------
 
   before(async function () {
-    this.timeout(30000)
+    this.timeout(60000)
 
     server = await createSingleServer(1)
 
index 089af8b159c0f45e198a8082ff4242c15277064c..b3fd8ecac8cfcf0894e32ff2a24a36bd88886c62 100644 (file)
@@ -254,6 +254,45 @@ describe('Test blocklist', function () {
         }
       })
 
+      it('Should get blocked status', async function () {
+        const remoteHandle = 'user2@' + servers[1].host
+        const localHandle = 'user1@' + servers[0].host
+        const unknownHandle = 'user5@' + servers[0].host
+
+        {
+          const status = await command.getStatus({ accounts: [ remoteHandle ] })
+          expect(Object.keys(status.accounts)).to.have.lengthOf(1)
+          expect(status.accounts[remoteHandle].blockedByUser).to.be.false
+          expect(status.accounts[remoteHandle].blockedByServer).to.be.false
+
+          expect(Object.keys(status.hosts)).to.have.lengthOf(0)
+        }
+
+        {
+          const status = await command.getStatus({ token: servers[0].accessToken, accounts: [ remoteHandle ] })
+          expect(Object.keys(status.accounts)).to.have.lengthOf(1)
+          expect(status.accounts[remoteHandle].blockedByUser).to.be.true
+          expect(status.accounts[remoteHandle].blockedByServer).to.be.false
+
+          expect(Object.keys(status.hosts)).to.have.lengthOf(0)
+        }
+
+        {
+          const status = await command.getStatus({ token: servers[0].accessToken, accounts: [ localHandle, remoteHandle, unknownHandle ] })
+          expect(Object.keys(status.accounts)).to.have.lengthOf(3)
+
+          for (const handle of [ localHandle, remoteHandle ]) {
+            expect(status.accounts[handle].blockedByUser).to.be.true
+            expect(status.accounts[handle].blockedByServer).to.be.false
+          }
+
+          expect(status.accounts[unknownHandle].blockedByUser).to.be.false
+          expect(status.accounts[unknownHandle].blockedByServer).to.be.false
+
+          expect(Object.keys(status.hosts)).to.have.lengthOf(0)
+        }
+      })
+
       it('Should not allow a remote blocked user to comment my videos', async function () {
         this.timeout(60000)
 
@@ -434,6 +473,35 @@ describe('Test blocklist', function () {
         expect(block.blockedServer.host).to.equal('localhost:' + servers[1].port)
       })
 
+      it('Should get blocklist status', async function () {
+        const blockedServer = servers[1].host
+        const notBlockedServer = 'example.com'
+
+        {
+          const status = await command.getStatus({ hosts: [ blockedServer, notBlockedServer ] })
+          expect(Object.keys(status.accounts)).to.have.lengthOf(0)
+
+          expect(Object.keys(status.hosts)).to.have.lengthOf(2)
+          expect(status.hosts[blockedServer].blockedByUser).to.be.false
+          expect(status.hosts[blockedServer].blockedByServer).to.be.false
+
+          expect(status.hosts[notBlockedServer].blockedByUser).to.be.false
+          expect(status.hosts[notBlockedServer].blockedByServer).to.be.false
+        }
+
+        {
+          const status = await command.getStatus({ token: servers[0].accessToken, hosts: [ blockedServer, notBlockedServer ] })
+          expect(Object.keys(status.accounts)).to.have.lengthOf(0)
+
+          expect(Object.keys(status.hosts)).to.have.lengthOf(2)
+          expect(status.hosts[blockedServer].blockedByUser).to.be.true
+          expect(status.hosts[blockedServer].blockedByServer).to.be.false
+
+          expect(status.hosts[notBlockedServer].blockedByUser).to.be.false
+          expect(status.hosts[notBlockedServer].blockedByServer).to.be.false
+        }
+      })
+
       it('Should unblock the remote server', async function () {
         await command.removeFromMyBlocklist({ server: 'localhost:' + servers[1].port })
       })
@@ -575,6 +643,27 @@ describe('Test blocklist', function () {
         }
       })
 
+      it('Should get blocked status', async function () {
+        const remoteHandle = 'user2@' + servers[1].host
+        const localHandle = 'user1@' + servers[0].host
+        const unknownHandle = 'user5@' + servers[0].host
+
+        for (const token of [ undefined, servers[0].accessToken ]) {
+          const status = await command.getStatus({ token, accounts: [ localHandle, remoteHandle, unknownHandle ] })
+          expect(Object.keys(status.accounts)).to.have.lengthOf(3)
+
+          for (const handle of [ localHandle, remoteHandle ]) {
+            expect(status.accounts[handle].blockedByUser).to.be.false
+            expect(status.accounts[handle].blockedByServer).to.be.true
+          }
+
+          expect(status.accounts[unknownHandle].blockedByUser).to.be.false
+          expect(status.accounts[unknownHandle].blockedByServer).to.be.false
+
+          expect(Object.keys(status.hosts)).to.have.lengthOf(0)
+        }
+      })
+
       it('Should unblock the remote account', async function () {
         await command.removeFromServerBlocklist({ account: 'user2@localhost:' + servers[1].port })
       })
@@ -620,6 +709,7 @@ describe('Test blocklist', function () {
     })
 
     describe('When managing server blocklist', function () {
+
       it('Should list all videos', async function () {
         for (const token of [ userModeratorToken, servers[0].accessToken ]) {
           await checkAllVideos(servers[0], token)
@@ -713,6 +803,23 @@ describe('Test blocklist', function () {
         expect(block.blockedServer.host).to.equal('localhost:' + servers[1].port)
       })
 
+      it('Should get blocklist status', async function () {
+        const blockedServer = servers[1].host
+        const notBlockedServer = 'example.com'
+
+        for (const token of [ undefined, servers[0].accessToken ]) {
+          const status = await command.getStatus({ token, hosts: [ blockedServer, notBlockedServer ] })
+          expect(Object.keys(status.accounts)).to.have.lengthOf(0)
+
+          expect(Object.keys(status.hosts)).to.have.lengthOf(2)
+          expect(status.hosts[blockedServer].blockedByUser).to.be.false
+          expect(status.hosts[blockedServer].blockedByServer).to.be.true
+
+          expect(status.hosts[notBlockedServer].blockedByUser).to.be.false
+          expect(status.hosts[notBlockedServer].blockedByServer).to.be.false
+        }
+      })
+
       it('Should unblock the remote server', async function () {
         await command.removeFromServerBlocklist({ server: 'localhost:' + servers[1].port })
       })
index 468efdf35b33aa4385d01bd8f024d7c4188ddb74..9af20843ecaca8e34b744fd85f8724b89873fe44 100644 (file)
@@ -267,7 +267,7 @@ describe('Test user notifications', function () {
     })
 
     it('Should send a notification when an imported video is transcoded', async function () {
-      this.timeout(50000)
+      this.timeout(120000)
 
       const name = 'video import ' + buildUUID()
 
index ea524723cfee2d20976fba22ffd87c7fb8e80f92..96ec17b0fce77cb2be2ec9badf49454d1fb818a9 100644 (file)
@@ -43,6 +43,9 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
   expect(data.services.twitter.username).to.equal('@Chocobozzz')
   expect(data.services.twitter.whitelisted).to.be.false
 
+  expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.false
+  expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.false
+
   expect(data.cache.previews.size).to.equal(1)
   expect(data.cache.captions.size).to.equal(1)
   expect(data.cache.torrents.size).to.equal(1)
@@ -138,6 +141,9 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.services.twitter.username).to.equal('@Kuja')
   expect(data.services.twitter.whitelisted).to.be.true
 
+  expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.true
+  expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.true
+
   expect(data.cache.previews.size).to.equal(2)
   expect(data.cache.captions.size).to.equal(3)
   expect(data.cache.torrents.size).to.equal(4)
@@ -246,6 +252,18 @@ const newCustomConfig: CustomConfig = {
       whitelisted: true
     }
   },
+  client: {
+    videos: {
+      miniature: {
+        preferAuthorDisplayName: true
+      }
+    },
+    menu: {
+      login: {
+        redirectOnSingleExternalAuth: true
+      }
+    }
+  },
   cache: {
     previews: {
       size: 2
index f2a9849628547a11b853a35b05e13c96297a9f68..3ac440f841aea58c7bde291c121a7ead926fed45 100644 (file)
@@ -207,6 +207,25 @@ describe('Test CLI wrapper', function () {
 
       expect(res).to.not.contain('peertube-plugin-hello-world')
     })
+
+    it('Should install a plugin in requested version', async function () {
+      this.timeout(60000)
+
+      await cliCommand.execWithEnv(`${cmd} plugins install --npm-name peertube-plugin-hello-world --plugin-version 0.0.17`)
+    })
+
+    it('Should list installed plugins, in correct version', async function () {
+      const res = await cliCommand.execWithEnv(`${cmd} plugins list`)
+
+      expect(res).to.contain('peertube-plugin-hello-world')
+      expect(res).to.contain('0.0.17')
+    })
+
+    it('Should uninstall the plugin again', async function () {
+      const res = await cliCommand.execWithEnv(`${cmd} plugins uninstall --npm-name peertube-plugin-hello-world`)
+
+      expect(res).to.not.contain('peertube-plugin-hello-world')
+    })
   })
 
   describe('Manage video redundancies', function () {
index ae625114d2012d7597deed308f7610e0e63d9746..9dd3f08c90095a2d20bf3670a3553c2394c7ffd6 100644 (file)
@@ -31,6 +31,7 @@ program
   .option('-p, --password <token>', 'Password')
   .option('-P --path <path>', 'Install from a path')
   .option('-n, --npm-name <npmName>', 'Install from npm')
+  .option('--plugin-version <pluginVersion>', 'Specify the plugin version to install (only available when installing from npm)')
   .action((options, command) => installPluginCLI(command, options))
 
 program
@@ -109,7 +110,7 @@ async function installPluginCLI (command: Command, options: OptionValues) {
   await assignToken(server, username, password)
 
   try {
-    await server.plugins.install({ npmName: options.npmName, path: options.path })
+    await server.plugins.install({ npmName: options.npmName, path: options.path, pluginVersion: options.pluginVersion })
   } catch (err) {
     console.error('Cannot install plugin.', err)
     process.exit(-1)
index 7a768b4df4151bd03e7d44f181cc2e7ffc8e4181..a061ca89e8e239523a5b8a85495d8affa96fed44 100644 (file)
@@ -194,6 +194,18 @@ export class ConfigCommand extends AbstractCommand {
           whitelisted: true
         }
       },
+      client: {
+        videos: {
+          miniature: {
+            preferAuthorDisplayName: false
+          }
+        },
+        menu: {
+          login: {
+            redirectOnSingleExternalAuth: false
+          }
+        }
+      },
       cache: {
         previews: {
           size: 2
index b944475a264647e83bcff2023844752c7cbae68d..9bf24afffc3aab16a98b92e6ce99ec0700123d00 100644 (file)
@@ -158,15 +158,16 @@ export class PluginsCommand extends AbstractCommand {
   install (options: OverrideCommandOptions & {
     path?: string
     npmName?: string
+    pluginVersion?: string
   }) {
-    const { npmName, path } = options
+    const { npmName, path, pluginVersion } = options
     const apiPath = '/api/v1/plugins/install'
 
     return this.postBodyRequest({
       ...options,
 
       path: apiPath,
-      fields: { npmName, path },
+      fields: { npmName, path, pluginVersion },
       implicitToken: true,
       defaultExpectedStatus: HttpStatusCode.OK_200
     })
index 14491a1aee596c744b8b88f8f21f538f7903ebcc..2e7ed074dc3f36df77d6d42df5238fc6d75eef67 100644 (file)
@@ -1,6 +1,6 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import { AccountBlock, HttpStatusCode, ResultList, ServerBlock } from '@shared/models'
+import { AccountBlock, BlockStatus, HttpStatusCode, ResultList, ServerBlock } from '@shared/models'
 import { AbstractCommand, OverrideCommandOptions } from '../shared'
 
 type ListBlocklistOptions = OverrideCommandOptions & {
@@ -37,6 +37,29 @@ export class BlocklistCommand extends AbstractCommand {
 
   // ---------------------------------------------------------------------------
 
+  getStatus (options: OverrideCommandOptions & {
+    accounts?: string[]
+    hosts?: string[]
+  }) {
+    const { accounts, hosts } = options
+
+    const path = '/api/v1/blocklist/status'
+
+    return this.getRequestBody<BlockStatus>({
+      ...options,
+
+      path,
+      query: {
+        accounts,
+        hosts
+      },
+      implicitToken: false,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   addToMyBlocklist (options: OverrideCommandOptions & {
     account?: string
     server?: string
diff --git a/shared/models/moderation/block-status.model.ts b/shared/models/moderation/block-status.model.ts
new file mode 100644 (file)
index 0000000..5973127
--- /dev/null
@@ -0,0 +1,15 @@
+export interface BlockStatus {
+  accounts: {
+    [ handle: string ]: {
+      blockedByServer: boolean
+      blockedByUser?: boolean
+    }
+  }
+
+  hosts: {
+    [ host: string ]: {
+      blockedByServer: boolean
+      blockedByUser?: boolean
+    }
+  }
+}
index 8b6042e9790b55fb986e42fcac258de1b8cb63d6..f8e6d351cf2adb0b145ee9cf11439b250bd45af2 100644 (file)
@@ -1,3 +1,4 @@
 export * from './abuse'
+export * from './block-status.model'
 export * from './account-block.model'
 export * from './server-block.model'
index b74dffbef71b19f3cf56276a0bdc60799bc7a4dd..8d23314b5ad98f18bcbc8c571a6b17342446df74 100644 (file)
@@ -1 +1,10 @@
-export type PluginSelectorId = 'login-form'
+export type PluginSelectorId =
+  'login-form' |
+  'menu-user-dropdown-language-item' |
+  'about-instance-features' |
+  'about-instance-statistics' |
+  'about-instance-moderation' |
+  'about-menu-instance' |
+  'about-menu-peertube' |
+  'about-menu-network' |
+  'about-instance-other-information'
index 5a268ebe18b436d852d1eb07c6f90451133c01c0..a1d009a00e1f7a7ccabe93fa59caf7f3d4068ee4 100644 (file)
@@ -1,4 +1,5 @@
 export interface InstallOrUpdatePlugin {
   npmName?: string
+  pluginVersion?: string
   path?: string
 }
index 3ed93249460c7a553456d6d30df27e21c936112e..52d3d958884cc2a8a516826f966e983b876f4ac5 100644 (file)
@@ -52,6 +52,20 @@ export interface CustomConfig {
     }
   }
 
+  client: {
+    videos: {
+      miniature: {
+        preferAuthorDisplayName: boolean
+      }
+    }
+
+    menu: {
+      login: {
+        redirectOnSingleExternalAuth: boolean
+      }
+    }
+  }
+
   cache: {
     previews: {
       size: number
index e75eefd47b05922f3d9e3e0e1b9c786e439adb81..9f17276e0ae9fb6bdb90af9dcb5e73f604eba288 100644 (file)
@@ -39,6 +39,12 @@ export interface ServerConfig {
         preferAuthorDisplayName: boolean
       }
     }
+
+    menu: {
+      login: {
+        redirectOnSingleExternalAuth: boolean
+      }
+    }
   }
 
   webadmin: {
index cfba7b361ac186cf4696cb854d54cd004d444ecb..1d5581072adab2e4c0fec5ba638286f757f5cc65 100644 (file)
@@ -3792,6 +3792,39 @@ paths:
         '500':
           description: search index unavailable
 
+  /blocklist/status:
+    get:
+      tags:
+        - Account Blocks
+        - Server Blocks
+      summary: Get block status of accounts/hosts
+      parameters:
+        -
+          name: 'accounts'
+          in: query
+          description: 'Check if these accounts are blocked'
+          example: [ 'goofy@example.com', 'donald@example.com' ]
+          schema:
+            type: array
+            items:
+              type: string
+        -
+          name: 'hosts'
+          in: query
+          description: 'Check if these hosts are blocked'
+          example: [ 'example.com' ]
+          schema:
+            type: array
+            items:
+              type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            'application/json':
+              schema:
+                $ref: '#/components/schemas/BlockStatus'
+
   /server/blocklist/accounts:
     get:
       tags:
@@ -5134,6 +5167,29 @@ components:
         label:
           type: string
 
+    BlockStatus:
+      properties:
+        accounts:
+          type: object
+          additionalProperties:
+            x-additionalPropertiesName: account
+            type: object
+            properties:
+              blockedByServer:
+                type: boolean
+              blockedByUser:
+                type: boolean
+        hosts:
+          type: object
+          additionalProperties:
+            x-additionalPropertiesName: host
+            type: object
+            properties:
+              blockedByServer:
+                type: boolean
+              blockedByUser:
+                type: boolean
+
     NSFWPolicy:
       type: string
       enum:
diff --git a/support/doc/development/ci.md b/support/doc/development/ci.md
new file mode 100644 (file)
index 0000000..7d6eef1
--- /dev/null
@@ -0,0 +1,40 @@
+# Continuous integration
+
+PeerTube uses Github Actions as a CI platform.
+CI tasks are described in `.github/workflows`.
+
+## benchmark.yml
+
+*Scheduled*
+
+Run various benchmarks (build, API etc) and upload results on https://builds.joinpeertube.org/peertube-stats/ to be publicly consumed.
+
+## codeql.yml
+
+*Scheduled, on push on develop and on pull request*
+
+Run CodeQL task to throw code security issues in Github. https://lgtm.com/projects/g/Chocobozzz/PeerTube can also be used.
+
+## docker.yml
+
+*Scheduled and on push on master*
+
+Build `chocobozzz/peertube-webserver:latest`, `chocobozzz/peertube:production-...`, `chocobozzz/peertube:v-...` (only latest PeerTube tag) and `chocobozzz/peertube:develop-...` Docker images. Scheduled to automatically upgrade image software (Debian security issues etc).
+
+## nightly.yml
+
+*Scheduled*
+
+Build PeerTube nightly build (`develop` branch) and upload the release on https://builds.joinpeertube.org/nightly.
+
+## stats.yml
+
+*On push on develop*
+
+Create various PeerTube stats (line of codes, build size, lighthouse report) and upload results on https://builds.joinpeertube.org/peertube-stats/ to be publicly consumed.
+
+## test.yml
+
+*Scheduled, on push and pull request*
+
+Run PeerTube lint and tests.
index 7b2650538022468120b6452dd3d2dcab943684a5..ec06d6b1d4df12e8f9c46b9f6c017bae0643f412 100644 (file)
@@ -21,10 +21,10 @@ WORKDIR /app
 
 USER peertube
 
-RUN yarn install --pure-lockfile \
+RUN yarn install --pure-lockfile --network-timeout 600000 \
     && npm run build -- $NPM_RUN_BUILD_OPTS \
     && rm -r ./node_modules ./client/node_modules \
-    && yarn install --pure-lockfile --production \
+    && yarn install --pure-lockfile --production --network-timeout 600000 \
     && yarn cache clean
 
 USER root
index ee35c5c7e4bca6ecf64efcb45491170ee9b76373..62c95cfff0a329072462c8ae6500b2268b4d312c 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -2613,11 +2613,6 @@ call-me-maybe@^1.0.1:
   resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
   integrity sha1-JtII6onje1y95gJQoV8DHBak1ms=
 
-callsite@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
-  integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
-
 callsites@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
@@ -3229,13 +3224,6 @@ debuglog@^1.0.0:
   resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
   integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=
 
-decache@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/decache/-/decache-4.6.0.tgz#87026bc6e696759e82d57a3841c4e251a30356e8"
-  integrity sha512-PppOuLiz+DFeaUvFXEYZjLxAkKiMYH/do/b/MxpDe/8AgKBi5GhZxridoVIbBq72GDbL36e4p0Ce2jTGUwwU+w==
-  dependencies:
-    callsite "^1.0.0"
-
 decamelize@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"