]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Merge branch 'release/5.0.0' into develop
authorChocobozzz <me@florianbigard.com>
Thu, 12 Jan 2023 07:54:13 +0000 (08:54 +0100)
committerChocobozzz <me@florianbigard.com>
Thu, 12 Jan 2023 07:54:13 +0000 (08:54 +0100)
146 files changed:
.github/workflows/test.yml
client/package.json
client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
client/src/app/+login/login.component.ts
client/src/app/+videos/+video-watch/video-watch.component.ts
client/src/app/+videos/video-list/videos-list-common-page.component.ts
client/src/app/core/auth/auth.service.ts
client/src/app/core/rest/rest-extractor.service.ts
client/src/app/menu/menu.component.ts
client/src/app/shared/shared-video-miniature/video-miniature.component.ts
client/src/app/shared/shared-video-miniature/videos-list.component.ts
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
client/src/app/shared/shared-video-playlist/video-playlist.service.ts
client/src/assets/player/peertube-player-manager.ts
client/src/assets/player/shared/control-bar/index.ts
client/src/assets/player/shared/control-bar/peertube-live-display.ts [new file with mode: 0644]
client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
client/src/assets/player/shared/stats/stats-card.ts
client/src/assets/player/types/manager-options.ts
client/src/assets/player/types/peertube-videojs-typings.ts
client/src/root-helpers/logger.ts
client/src/root-helpers/plugins-manager.ts
client/src/sass/player/control-bar.scss
client/src/sass/primeng-custom.scss
client/src/standalone/videos/shared/player-manager-options.ts
client/yarn.lock
config/default.yaml
config/production.yaml.example
package.json
scripts/i18n/create-custom-files.ts
server.ts
server/controllers/activitypub/client.ts
server/controllers/api/videos/comment.ts
server/controllers/api/videos/token.ts
server/controllers/feeds.ts
server/controllers/tracker.ts
server/helpers/custom-validators/misc.ts
server/helpers/custom-validators/video-captions.ts
server/helpers/custom-validators/video-imports.ts
server/helpers/decache.ts
server/helpers/memoize.ts [new file with mode: 0644]
server/helpers/youtube-dl/youtube-dl-cli.ts
server/initializers/checker-after-init.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/initializers/installer.ts
server/lib/auth/external-auth.ts
server/lib/auth/oauth-model.ts
server/lib/auth/oauth.ts
server/lib/auth/tokens-cache.ts
server/lib/job-queue/job-queue.ts
server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts [new file with mode: 0644]
server/lib/opentelemetry/metric-helpers/index.ts
server/lib/opentelemetry/metrics.ts
server/lib/plugins/plugin-helpers-builder.ts
server/lib/redis.ts
server/lib/sync-channel.ts
server/lib/video-comment.ts
server/lib/video-tokens-manager.ts
server/middlewares/sort.ts
server/middlewares/validators/shared/videos.ts
server/models/abuse/abuse-message.ts
server/models/abuse/abuse.ts
server/models/abuse/sql/abuse-query-builder.ts [moved from server/models/abuse/abuse-query-builder.ts with 97% similarity]
server/models/account/account-blocklist.ts
server/models/account/account-video-rate.ts
server/models/account/account.ts
server/models/actor/actor-follow.ts
server/models/actor/actor-image.ts
server/models/actor/actor.ts
server/models/actor/sql/instance-list-followers-query-builder.ts
server/models/actor/sql/instance-list-following-query-builder.ts
server/models/actor/sql/shared/actor-follow-table-attributes.ts
server/models/actor/sql/shared/instance-list-follows-query-builder.ts
server/models/redundancy/video-redundancy.ts
server/models/server/plugin.ts
server/models/server/server-blocklist.ts
server/models/server/server.ts
server/models/shared/index.ts
server/models/shared/model-builder.ts
server/models/shared/model-cache.ts [moved from server/models/model-cache.ts with 100% similarity]
server/models/shared/query.ts
server/models/shared/sequelize-helpers.ts [new file with mode: 0644]
server/models/shared/sort.ts [new file with mode: 0644]
server/models/shared/sql.ts [new file with mode: 0644]
server/models/shared/update.ts
server/models/user/sql/user-notitication-list-query-builder.ts
server/models/user/user-notification-setting.ts
server/models/user/user-notification.ts
server/models/user/user.ts
server/models/utils.ts [deleted file]
server/models/video/sql/comment/video-comment-list-query-builder.ts [new file with mode: 0644]
server/models/video/sql/comment/video-comment-table-attributes.ts [new file with mode: 0644]
server/models/video/sql/video/shared/abstract-video-query-builder.ts
server/models/video/sql/video/videos-id-list-query-builder.ts
server/models/video/tag.ts
server/models/video/video-blacklist.ts
server/models/video/video-caption.ts
server/models/video/video-change-ownership.ts
server/models/video/video-channel-sync.ts
server/models/video/video-channel.ts
server/models/video/video-comment.ts
server/models/video/video-file.ts
server/models/video/video-import.ts
server/models/video/video-playlist-element.ts
server/models/video/video-playlist.ts
server/models/video/video-share.ts
server/models/video/video-streaming-playlist.ts
server/models/video/video.ts
server/tests/api/activitypub/cleaner.ts
server/tests/api/check-params/redundancy.ts
server/tests/api/live/live-fast-restream.ts
server/tests/api/notifications/moderation-notifications.ts
server/tests/api/object-storage/video-static-file-privacy.ts
server/tests/api/users/index.ts
server/tests/api/users/oauth.ts [new file with mode: 0644]
server/tests/api/users/users.ts
server/tests/api/videos/video-channel-syncs.ts
server/tests/api/videos/video-comments.ts
server/tests/external-plugins/auto-block-videos.ts
server/tests/external-plugins/auto-mute.ts
server/tests/feeds/feeds.ts
server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
server/tests/fixtures/peertube-plugin-test-four/main.js
server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
server/tests/fixtures/peertube-plugin-test/main.js
server/tests/plugins/external-auth.ts
server/tests/plugins/filter-hooks.ts
server/tests/plugins/id-and-pass-auth.ts
server/tests/plugins/plugin-helpers.ts
server/types/express.d.ts
server/types/lib.d.ts [new file with mode: 0644]
server/types/plugins/register-server-auth.model.ts
server/types/plugins/register-server-option.model.ts
shared/core-utils/plugins/hooks.ts
shared/server-commands/miscs/sql-command.ts
shared/server-commands/requests/requests.ts
support/doc/dependencies.md
support/doc/docker.md
support/doc/plugins/guide.md
support/doc/production.md
tsconfig.json
yarn.lock

index 65e1acec60293ab594dddf849a661d14463d5d14..678b0674bc24c3cd4396817d9a7be3812cc1b54d 100644 (file)
@@ -48,6 +48,7 @@ jobs:
       ENABLE_OBJECT_STORAGE_TESTS: true
       OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }}
       OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }}
+      YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
     steps:
       - uses: actions/checkout@v3
index 115a4a1997b4ede9cf2b3a8161f02bc27589d1f4..6f88d4fb9ac7814d3e8a56b8d465654f743ab8b2 100644 (file)
@@ -52,8 +52,8 @@
     "@ngx-loading-bar/core": "^6.0.0",
     "@ngx-loading-bar/http-client": "^6.0.0",
     "@ngx-loading-bar/router": "^6.0.0",
-    "@peertube/p2p-media-loader-core": "^1.0.13",
-    "@peertube/p2p-media-loader-hlsjs": "^1.0.13",
+    "@peertube/p2p-media-loader-core": "^1.0.14",
+    "@peertube/p2p-media-loader-hlsjs": "^1.0.14",
     "@peertube/videojs-contextmenu": "^5.5.0",
     "@peertube/xliffmerge": "^2.0.3",
     "@popperjs/core": "^2.11.5",
index 43f1438e0cabe6b77ad4865a88e93e99c6428a44..174f5d29ca8ca3e4f44ca2fddf007d5fa668449c 100644 (file)
 
             <div class="peertube-select-container">
               <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
+                <option i18n value="publishedAt">Recently added videos</option>
+                <option i18n value="originallyPublishedAt">Original publication date</option>
+                <option i18n value="name">Name</option>
                 <option i18n value="hot">Hot videos</option>
-                <option i18n value="most-viewed">Most viewed videos</option>
+                <option i18n value="most-viewed">Recent views</option>
                 <option i18n value="most-liked">Most liked videos</option>
+                <option i18n value="views">Global views</option>
               </select>
             </div>
 
index c1705807f0052bbdec43d6a1ccc04089ed8c3c38..5f6aa842e1066599a67ca5a30edba39f36c0f5bd 100644 (file)
@@ -1,3 +1,4 @@
+import { environment } from 'src/environments/environment'
 import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
@@ -7,7 +8,7 @@ import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-valid
 import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
 import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
 import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
-import { PluginsManager } from '@root-helpers/plugins-manager'
+import { getExternalAuthHref } from '@shared/core-utils'
 import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
 
 @Component({
@@ -119,7 +120,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
   }
 
   getAuthHref (auth: RegisteredExternalAuthConfig) {
-    return PluginsManager.getExternalAuthHref(auth)
+    return getExternalAuthHref(environment.apiUrl, auth)
   }
 
   login () {
index 94853423b085313c006dafa9c8ac779fd8766576..84548de97f8b9983e06a8d5b7f92210765946f3c 100644 (file)
@@ -133,8 +133,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     this.loadRouteParams()
     this.loadRouteQuery()
 
-    this.initHotkeys()
-
     this.theaterEnabled = getStoredTheater()
 
     this.hooks.runAction('action:video-watch.init', 'video-watch')
@@ -295,6 +293,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
           subtitle: queryParams.subtitle,
 
           playerMode: queryParams.mode,
+          playbackRate: queryParams.playbackRate,
           peertubeLink: false
         }
 
@@ -406,6 +405,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       if (res === false) return this.location.back()
     }
 
+    this.buildHotkeysHelp(video)
+
     this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay })
       .catch(err => logger.error('Cannot build the player', err))
 
@@ -657,6 +658,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
         muted: urlOptions.muted,
         loop: urlOptions.loop,
         subtitle: urlOptions.subtitle,
+        playbackRate: urlOptions.playbackRate,
 
         peertubeLink: urlOptions.peertubeLink,
 
@@ -785,33 +787,43 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     this.video.viewers = newViewers
   }
 
-  private initHotkeys () {
+  private buildHotkeysHelp (video: Video) {
+    if (this.hotkeys.length !== 0) {
+      this.hotkeysService.remove(this.hotkeys)
+    }
+
     this.hotkeys = [
       // These hotkeys are managed by the player
       new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen`),
       new Hotkey('space', e => e, undefined, $localize`Play/Pause the video`),
       new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video`),
 
-      new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
-
       new Hotkey('up', e => e, undefined, $localize`Increase the volume`),
       new Hotkey('down', e => e, undefined, $localize`Decrease the volume`),
 
-      new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
-      new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
-
-      new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
-      new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
-
-      new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
-      new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`),
-
       new Hotkey('t', e => {
         this.theaterEnabled = !this.theaterEnabled
         return false
       }, undefined, $localize`Toggle theater mode`)
     ]
 
+    if (!video.isLive) {
+      this.hotkeys = this.hotkeys.concat([
+        // These hotkeys are also managed by the player but only for VOD
+
+        new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
+
+        new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
+        new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
+
+        new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
+        new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
+
+        new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
+        new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`)
+      ])
+    }
+
     if (this.isUserLoggedIn()) {
       this.hotkeys = this.hotkeys.concat([
         new Hotkey('shift+s', () => {
index c8fa8ef302c59bc9d0f68a4a67ec8ccd6ce9752b..bafe30fd78953bc7bee2cfb8b18d84994fac3949 100644 (file)
@@ -177,6 +177,9 @@ export class VideosListCommonPageComponent implements OnInit, OnDestroy, Disable
       case 'best':
         return '-hot'
 
+      case 'name':
+        return 'name'
+
       default:
         return '-' + algorithm as VideoSortField
     }
index 4de28e51e9d95ee53c969b882e53065a43e617ca..ed7eabb76c5442dae30e1379b9b04d96b24603e7 100644 (file)
@@ -5,10 +5,11 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular
 import { Injectable } from '@angular/core'
 import { Router } from '@angular/router'
 import { Notifier } from '@app/core/notification/notifier.service'
-import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index'
+import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage, PluginsManager } from '@root-helpers/index'
 import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models'
 import { environment } from '../../../environments/environment'
 import { RestExtractor } from '../rest/rest-extractor.service'
+import { ServerService } from '../server'
 import { AuthStatus } from './auth-status.model'
 import { AuthUser } from './auth-user.model'
 
@@ -44,6 +45,7 @@ export class AuthService {
   private refreshingTokenObservable: Observable<any>
 
   constructor (
+    private serverService: ServerService,
     private http: HttpClient,
     private notifier: Notifier,
     private hotkeysService: HotkeysService,
@@ -213,25 +215,28 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
     const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
 
     this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers })
-                                         .pipe(
-                                           map(res => this.handleRefreshToken(res)),
-                                           tap(() => {
-                                             this.refreshingTokenObservable = null
-                                           }),
-                                           catchError(err => {
-                                             this.refreshingTokenObservable = null
-
-                                             logger.error(err)
-                                             logger.info('Cannot refresh token -> logout...')
-                                             this.logout()
-                                             this.router.navigate([ '/login' ])
-
-                                             return observableThrowError(() => ({
-                                               error: $localize`You need to reconnect.`
-                                             }))
-                                           }),
-                                           share()
-                                         )
+      .pipe(
+        map(res => this.handleRefreshToken(res)),
+        tap(() => {
+          this.refreshingTokenObservable = null
+        }),
+        catchError(err => {
+          this.refreshingTokenObservable = null
+
+          logger.error(err)
+          logger.info('Cannot refresh token -> logout...')
+          this.logout()
+
+          const externalLoginUrl = PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverService.getHTMLConfig())
+          if (externalLoginUrl) window.location.href = externalLoginUrl
+          else this.router.navigate([ '/login' ])
+
+          return observableThrowError(() => ({
+            error: $localize`You need to reconnect.`
+          }))
+        }),
+        share()
+      )
 
     return this.refreshingTokenObservable
   }
index de3f2bfff7f0cb64dd30d5db41ed7e45e4f73232..daed7f1785aa9e10a9f6853fcf6409f9d032e5f0 100644 (file)
@@ -87,7 +87,11 @@ export class RestExtractor {
 
     if (err.status !== undefined) {
       const errorMessage = this.buildServerErrorMessage(err)
-      logger.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
+
+      const message = `Backend returned code ${err.status}, errorMessage is: ${errorMessage}`
+
+      if (err.status === HttpStatusCode.NOT_FOUND_404) logger.clientError(message)
+      else logger.error(message)
 
       return errorMessage
     }
index 63f01df92f560b891a8b10bbbc4feab2b6bde463..568cb98bb2e9950b19126192af44fded2796078e 100644 (file)
@@ -1,6 +1,7 @@
 import { HotkeysService } from 'angular2-hotkeys'
 import * as debug from 'debug'
 import { switchMap } from 'rxjs/operators'
+import { environment } from 'src/environments/environment'
 import { ViewportScroller } from '@angular/common'
 import { Component, OnInit, ViewChild } from '@angular/core'
 import { Router } from '@angular/router'
@@ -131,12 +132,7 @@ export class MenuComponent implements OnInit {
   }
 
   getExternalLoginHref () {
-    if (!this.serverConfig || this.serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
-
-    const externalAuths = this.serverConfig.plugin.registeredExternalAuths
-    if (externalAuths.length !== 1) return undefined
-
-    return PluginsManager.getExternalAuthHref(externalAuths[0])
+    return PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverConfig)
   }
 
   isRegistrationAllowed () {
index 85c63c1738467c67c47954a1b13a805cdf55028d..706227e66339e2a05d499913c2dc697220b156d7 100644 (file)
@@ -314,6 +314,6 @@ export class VideoMiniatureComponent implements OnInit {
           this.cd.markForCheck()
         })
 
-    this.videoPlaylistService.runPlaylistCheck(this.video.id)
+    this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
   }
 }
index d5cdd958e1dbd1fa3d2986f5233b2466731196da..a423377de1c125526850f9f879054b16b3d795ff 100644 (file)
@@ -1,6 +1,6 @@
 import * as debug from 'debug'
 import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
-import { debounceTime, switchMap } from 'rxjs/operators'
+import { concatMap, debounceTime, map, switchMap } from 'rxjs/operators'
 import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
 import {
@@ -111,6 +111,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
 
   private lastQueryLength: number
 
+  private videoRequests = new Subject<{ reset: boolean, obs: Observable<ResultList<Video>> }>()
+
   constructor (
     private notifier: Notifier,
     private authService: AuthService,
@@ -124,6 +126,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
   }
 
   ngOnInit () {
+    this.subscribeToVideoRequests()
+
     const hiddenFilters = this.hideScopeFilter
       ? [ 'scope' ]
       : []
@@ -228,30 +232,12 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
   }
 
   loadMoreVideos (reset = false) {
-    if (reset) this.hasDoneFirstQuery = false
-
-    this.getVideosObservableFunction(this.pagination, this.filters)
-      .subscribe({
-        next: ({ data }) => {
-          this.hasDoneFirstQuery = true
-          this.lastQueryLength = data.length
-
-          if (reset) this.videos = []
-          this.videos = this.videos.concat(data)
-
-          if (this.groupByDate) this.buildGroupedDateLabels()
-
-          this.onDataSubject.next(data)
-          this.videosLoaded.emit(this.videos)
-        },
-
-        error: err => {
-          const message = $localize`Cannot load more videos. Try again later.`
+    if (reset) {
+      this.hasDoneFirstQuery = false
+      this.videos = []
+    }
 
-          logger.error(message, err)
-          this.notifier.error(message)
-        }
-      })
+    this.videoRequests.next({ reset, obs: this.getVideosObservableFunction(this.pagination, this.filters) })
   }
 
   reloadVideos () {
@@ -423,4 +409,32 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
       this.onFiltersChanged(true)
     })
   }
+
+  private subscribeToVideoRequests () {
+    this.videoRequests
+      .pipe(concatMap(({ reset, obs }) => obs.pipe(map(({ data }) => ({ data, reset })))))
+      .subscribe({
+        next: ({ data, reset }) => {
+          console.log(data[0].name)
+
+          this.hasDoneFirstQuery = true
+          this.lastQueryLength = data.length
+
+          if (reset) this.videos = []
+          this.videos = this.videos.concat(data)
+
+          if (this.groupByDate) this.buildGroupedDateLabels()
+
+          this.onDataSubject.next(data)
+          this.videosLoaded.emit(this.videos)
+        },
+
+        error: err => {
+          const message = $localize`Cannot load more videos. Try again later.`
+
+          logger.error(message, err)
+          this.notifier.error(message)
+        }
+      })
+  }
 }
index 2fc39fc759f7aaa1a132b78c63ab73077cf03f6c..f802416a4235a8c637d699f6b36e095c4227d010 100644 (file)
@@ -81,7 +81,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
         .subscribe(result => {
           this.playlistsData = result.data
 
-          this.videoPlaylistService.runPlaylistCheck(this.video.id)
+          this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
         })
 
     this.videoPlaylistSearchChanged
@@ -129,7 +129,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
         .subscribe(playlistsResult => {
           this.playlistsData = playlistsResult.data
 
-          this.videoPlaylistService.runPlaylistCheck(this.video.id)
+          this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
         })
   }
 
index 330a51f91a1d878a901c701b45bfc5ff9f7a674f..bc9fb0d7443af034732b7443a405664c6ba5bd86 100644 (file)
@@ -206,7 +206,15 @@ export class VideoPlaylistService {
                      stopTimestamp: body.stopTimestamp
                    })
 
-                   this.runPlaylistCheck(body.videoId)
+                   this.runVideoExistsInPlaylistCheck(body.videoId)
+
+                   if (this.myAccountPlaylistCache) {
+                     const playlist = this.myAccountPlaylistCache.data.find(p => p.id === playlistId)
+                     if (!playlist) return
+
+                     const otherPlaylists = this.myAccountPlaylistCache.data.filter(p => p !== playlist)
+                     this.myAccountPlaylistCache.data = [ playlist, ...otherPlaylists ]
+                   }
                  }),
                  catchError(err => this.restExtractor.handleError(err))
                )
@@ -225,7 +233,7 @@ export class VideoPlaylistService {
                      elem.stopTimestamp = body.stopTimestamp
                    }
 
-                   this.runPlaylistCheck(videoId)
+                   this.runVideoExistsInPlaylistCheck(videoId)
                  }),
                  catchError(err => this.restExtractor.handleError(err))
                )
@@ -242,7 +250,7 @@ export class VideoPlaylistService {
                        .filter(e => e.playlistElementId !== playlistElementId)
                    }
 
-                   this.runPlaylistCheck(videoId)
+                   this.runVideoExistsInPlaylistCheck(videoId)
                  }),
                  catchError(err => this.restExtractor.handleError(err))
                )
@@ -296,7 +304,7 @@ export class VideoPlaylistService {
     return obs
   }
 
-  runPlaylistCheck (videoId: number) {
+  runVideoExistsInPlaylistCheck (videoId: number) {
     debugLogger('Running playlist check.')
 
     if (this.videoExistsCache[videoId]) {
index 56310c4e94a9657f62b049b3524c34c6598e31cb..2781850b92bc34a567e9036a0065b04df4a57c63 100644 (file)
@@ -11,6 +11,7 @@ import './shared/control-bar/p2p-info-button'
 import './shared/control-bar/peertube-link-button'
 import './shared/control-bar/peertube-load-progress-bar'
 import './shared/control-bar/theater-button'
+import './shared/control-bar/peertube-live-display'
 import './shared/settings/resolution-menu-button'
 import './shared/settings/resolution-menu-item'
 import './shared/settings/settings-dialog'
@@ -96,6 +97,10 @@ export class PeertubePlayerManager {
       videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
         const player = this
 
+        if (!isNaN(+options.common.playbackRate)) {
+          player.playbackRate(+options.common.playbackRate)
+        }
+
         let alreadyFallback = false
 
         const handleError = () => {
@@ -118,7 +123,7 @@ export class PeertubePlayerManager {
         self.addContextMenu(videojsOptionsBuilder, player, options.common)
 
         if (isMobile()) player.peertubeMobile()
-        if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin()
+        if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive })
         if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden')
 
         player.bezels()
index db5b8938db3d382de88dd6231a3ee2a11cc43adf..e71e90713894d04f69141610291c5ede76b914a3 100644 (file)
@@ -1,5 +1,6 @@
 export * from './next-previous-video-button'
 export * from './p2p-info-button'
 export * from './peertube-link-button'
+export * from './peertube-live-display'
 export * from './peertube-load-progress-bar'
 export * from './theater-button'
diff --git a/client/src/assets/player/shared/control-bar/peertube-live-display.ts b/client/src/assets/player/shared/control-bar/peertube-live-display.ts
new file mode 100644 (file)
index 0000000..649eb0b
--- /dev/null
@@ -0,0 +1,93 @@
+import videojs from 'video.js'
+import { PeerTubeLinkButtonOptions } from '../../types'
+
+const ClickableComponent = videojs.getComponent('ClickableComponent')
+
+class PeerTubeLiveDisplay extends ClickableComponent {
+  private interval: any
+
+  private contentEl_: any
+
+  constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) {
+    super(player, options as any)
+
+    this.interval = this.setInterval(() => this.updateClass(), 1000)
+
+    this.show()
+    this.updateSync(true)
+  }
+
+  dispose () {
+    if (this.interval) {
+      this.clearInterval(this.interval)
+      this.interval = undefined
+    }
+
+    this.contentEl_ = null
+
+    super.dispose()
+  }
+
+  createEl () {
+    const el = super.createEl('div', {
+      className: 'vjs-live-control vjs-control'
+    })
+
+    this.contentEl_ = videojs.dom.createEl('div', {
+      className: 'vjs-live-display'
+    }, {
+      'aria-live': 'off'
+    })
+
+    this.contentEl_.appendChild(videojs.dom.createEl('span', {
+      className: 'vjs-control-text',
+      textContent: `${this.localize('Stream Type')}\u00a0`
+    }))
+
+    this.contentEl_.appendChild(document.createTextNode(this.localize('LIVE')))
+
+    el.appendChild(this.contentEl_)
+    return el
+  }
+
+  handleClick () {
+    const hlsjs = this.getHLSJS()
+    if (!hlsjs) return
+
+    this.player().currentTime(hlsjs.liveSyncPosition)
+    this.player().play()
+    this.updateSync(true)
+  }
+
+  private updateClass () {
+    const hlsjs = this.getHLSJS()
+    if (!hlsjs) return
+
+    // Not loaded yet
+    if (this.player().currentTime() === 0) return
+
+    const isSync = Math.abs(this.player().currentTime() - hlsjs.liveSyncPosition) < 10
+    this.updateSync(isSync)
+  }
+
+  private updateSync (isSync: boolean) {
+    if (isSync) {
+      this.addClass('synced-with-live-edge')
+      this.removeAttribute('title')
+      this.disable()
+    } else {
+      this.removeClass('synced-with-live-edge')
+      this.setAttribute('title', this.localize('Go back to the live'))
+      this.enable()
+    }
+  }
+
+  private getHLSJS () {
+    const p2pMediaLoader = this.player()?.p2pMediaLoader
+    if (!p2pMediaLoader) return undefined
+
+    return p2pMediaLoader().getHLSJS()
+  }
+}
+
+videojs.registerComponent('PeerTubeLiveDisplay', PeerTubeLiveDisplay)
index ec1e1038bfed90f7bb2bb342d9fbc45fa920da5b..f5b4b3919fdc5a96b1d75d701c302c0d61091969 100644 (file)
@@ -4,6 +4,10 @@ type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardE
 
 const Plugin = videojs.getPlugin('plugin')
 
+export type HotkeysOptions = {
+  isLive: boolean
+}
+
 class PeerTubeHotkeysPlugin extends Plugin {
   private static readonly VOLUME_STEP = 0.1
   private static readonly SEEK_STEP = 5
@@ -12,9 +16,13 @@ class PeerTubeHotkeysPlugin extends Plugin {
 
   private readonly handlers: KeyHandler[]
 
-  constructor (player: videojs.Player, options: videojs.PlayerOptions) {
+  private readonly isLive: boolean
+
+  constructor (player: videojs.Player, options: videojs.PlayerOptions & HotkeysOptions) {
     super(player, options)
 
+    this.isLive = options.isLive
+
     this.handlers = this.buildHandlers()
 
     this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event)
@@ -68,28 +76,6 @@ class PeerTubeHotkeysPlugin extends Plugin {
         }
       },
 
-      // Rewind
-      {
-        accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
-        cb: e => {
-          e.preventDefault()
-
-          const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
-          this.player.currentTime(target)
-        }
-      },
-
-      // Forward
-      {
-        accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
-        cb: e => {
-          e.preventDefault()
-
-          const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
-          this.player.currentTime(target)
-        }
-      },
-
       // Fullscreen
       {
         // f key or Ctrl + Enter
@@ -116,6 +102,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
       {
         accept: e => e.key === '>',
         cb: () => {
+          if (this.isLive) return
+
           const target = Math.min(this.player.playbackRate() + 0.1, 5)
 
           this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -126,6 +114,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
       {
         accept: e => e.key === '<',
         cb: () => {
+          if (this.isLive) return
+
           const target = Math.max(this.player.playbackRate() - 0.1, 0.10)
 
           this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -136,6 +126,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
       {
         accept: e => e.key === ',',
         cb: () => {
+          if (this.isLive) return
+
           this.player.pause()
 
           // Calculate movement distance (assuming 30 fps)
@@ -148,6 +140,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
       {
         accept: e => e.key === '.',
         cb: () => {
+          if (this.isLive) return
+
           this.player.pause()
 
           // Calculate movement distance (assuming 30 fps)
@@ -157,11 +151,47 @@ class PeerTubeHotkeysPlugin extends Plugin {
       }
     ]
 
+    if (this.isLive) return handlers
+
+    return handlers.concat(this.buildVODHandlers())
+  }
+
+  private buildVODHandlers () {
+    const handlers: KeyHandler[] = [
+      // Rewind
+      {
+        accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
+        cb: e => {
+          if (this.isLive) return
+
+          e.preventDefault()
+
+          const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
+          this.player.currentTime(target)
+        }
+      },
+
+      // Forward
+      {
+        accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
+        cb: e => {
+          if (this.isLive) return
+
+          e.preventDefault()
+
+          const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
+          this.player.currentTime(target)
+        }
+      }
+    ]
+
     // 0-9 key handlers
     for (let i = 0; i < 10; i++) {
       handlers.push({
         accept: e => this.isNakedOrShift(e, i + ''),
         cb: e => {
+          if (this.isLive) return
+
           e.preventDefault()
 
           this.player.currentTime(this.player.duration() * i * 0.1)
index 27f3667321933a9a1898df1590afb8d900f126f7..26f923e922cf6165eeb89b14dae1860f4e31e214 100644 (file)
@@ -30,10 +30,7 @@ export class ControlBarOptionsBuilder {
     }
 
     Object.assign(children, {
-      currentTimeDisplay: {},
-      timeDivider: {},
-      durationDisplay: {},
-      liveDisplay: {},
+      ...this.getTimeControls(),
 
       flexibleWidthSpacer: {},
 
@@ -74,7 +71,9 @@ export class ControlBarOptionsBuilder {
   private getSettingsButton () {
     const settingEntries: string[] = []
 
-    settingEntries.push('playbackRateMenuButton')
+    if (!this.options.isLive) {
+      settingEntries.push('playbackRateMenuButton')
+    }
 
     if (this.options.captions === true) settingEntries.push('captionsButton')
 
@@ -90,7 +89,23 @@ export class ControlBarOptionsBuilder {
     }
   }
 
+  private getTimeControls () {
+    if (this.options.isLive) {
+      return {
+        peerTubeLiveDisplay: {}
+      }
+    }
+
+    return {
+      currentTimeDisplay: {},
+      timeDivider: {},
+      durationDisplay: {}
+    }
+  }
+
   private getProgressControl () {
+    if (this.options.isLive) return {}
+
     const loadProgressBar = this.mode === 'webtorrent'
       ? 'peerTubeLoadProgressBar'
       : 'loadProgressBar'
index a14beb347ae290c74fd215e25dcf294478e982c6..7f7d90ab9383434d557a2fc026cbee72a0e7a81c 100644 (file)
@@ -281,8 +281,8 @@ class Html5Hlsjs {
     if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1
     else this.errorCounts[data.type] = 1
 
-    if (data.fatal) logger.warn(error.message)
-    else logger.error(error.message, { data })
+    if (data.fatal) logger.error(error.message, { currentTime: this.player.currentTime(), data })
+    else logger.warn(error.message)
 
     if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) {
       error.code = 2
index f23ae48be2c6fd33f1b190f33826513da30f863d..471a5e46c9e98571241fc670f20e768964c9e0f3 100644 (file)
@@ -182,7 +182,7 @@ class StatsCard extends Component {
     let colorSpace = 'unknown'
     let codecs = 'unknown'
 
-    if (metadata?.streams[0]) {
+    if (metadata?.streams?.[0]) {
       const stream = metadata.streams[0]
 
       colorSpace = stream['color_space'] !== 'unknown'
@@ -193,7 +193,7 @@ class StatsCard extends Component {
     }
 
     const resolution = videoFile?.resolution.label + videoFile?.fps
-    const buffer = this.timeRangesToString(this.player().buffered())
+    const buffer = this.timeRangesToString(this.player_.buffered())
     const progress = this.player_.webtorrent().getTorrent()?.progress
 
     return {
index 3057a5adbdad0714aaf4b5af4b8c895e3fd79aec..3fbcec29c79621f115ddceb9c66c03d0a5d52dd1 100644 (file)
@@ -29,6 +29,8 @@ export interface CustomizationOptions {
   resume?: string
 
   peertubeLink: boolean
+
+  playbackRate?: number | string
 }
 
 export interface CommonOptions extends CustomizationOptions {
index c60154f3b6369d642ceb2da495cb5540f9a58ba5..5674f78cbf2aa500c2da4eecc3d63c7a2315523b 100644 (file)
@@ -3,6 +3,7 @@ import videojs from 'video.js'
 import { Engine } from '@peertube/p2p-media-loader-hlsjs'
 import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
 import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
+import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin'
 import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
 import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
 import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
@@ -44,7 +45,7 @@ declare module 'video.js' {
 
     bezels (): void
     peertubeMobile (): void
-    peerTubeHotkeysPlugin (): void
+    peerTubeHotkeysPlugin (options?: HotkeysOptions): void
 
     stats (options?: StatsCardOptions): StatsForNerdsPlugin
 
index d1fdf73aaa0ee3f4d48746b3bd3746f4dfabcb00..618be62cdd3e5a5eb54b502c61d395899029842c 100644 (file)
@@ -27,6 +27,10 @@ class Logger {
   warn (message: LoggerMessage, meta?: LoggerMeta) {
     this.runHooks('warn', message, meta)
 
+    this.clientWarn(message, meta)
+  }
+
+  clientWarn (message: LoggerMessage, meta?: LoggerMeta) {
     if (meta) console.warn(message, meta)
     else console.warn(message)
   }
@@ -34,6 +38,10 @@ class Logger {
   error (message: LoggerMessage, meta?: LoggerMeta) {
     this.runHooks('error', message, meta)
 
+    this.clientError(message, meta)
+  }
+
+  clientError (message: LoggerMessage, meta?: LoggerMeta) {
     if (meta) console.error(message, meta)
     else console.error(message)
   }
index 6c64e2b014167f3559a74d1180e265a8f2737c47..e5b06a94cbe3173ca9d713d2dc755ccead4ecc9b 100644 (file)
@@ -3,7 +3,7 @@ import * as debug from 'debug'
 import { firstValueFrom, ReplaySubject } from 'rxjs'
 import { first, shareReplay } from 'rxjs/operators'
 import { RegisterClientHelpers } from 'src/types/register-client-option.model'
-import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
+import { getExternalAuthHref, getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
 import {
   ClientHookName,
   clientHookObject,
@@ -16,7 +16,6 @@ import {
   RegisterClientRouteOptions,
   RegisterClientSettingsScriptOptions,
   RegisterClientVideoFieldOptions,
-  RegisteredExternalAuthConfig,
   ServerConfigPlugin
 } from '@shared/models'
 import { environment } from '../environments/environment'
@@ -94,9 +93,13 @@ class PluginsManager {
     return isTheme ? '/themes' : '/plugins'
   }
 
-  static getExternalAuthHref (auth: RegisteredExternalAuthConfig) {
-    return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
+  static getDefaultLoginHref (apiUrl: string, serverConfig: HTMLServerConfig) {
+    if (!serverConfig || serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
 
+    const externalAuths = serverConfig.plugin.registeredExternalAuths
+    if (externalAuths.length !== 1) return undefined
+
+    return getExternalAuthHref(apiUrl, externalAuths[0])
   }
 
   loadPluginsList (config: HTMLServerConfig) {
index 0082378e44add6f6500f04b69f562aa720f977a5..96b3adf66caf58179c287b9cd5b350cac54bb768 100644 (file)
   }
 
   .vjs-live-control {
-    line-height: $control-bar-height;
-    min-width: 4em;
+    padding: 5px 7px;
+    border-radius: 3px;
+    height: fit-content;
+    margin: auto 10px;
+    font-weight: bold;
+    max-width: fit-content;
+    opacity: 1 !important;
+    line-height: normal;
+    position: relative;
+    top: -1px;
+
+    &.synced-with-live-edge {
+      background: #d7281c;
+    }
+
+    &:not(.synced-with-live-edge) {
+      cursor: pointer;
+      background: #80807f;
+    }
   }
 
   .vjs-peertube {
index 88f6efb6a0602407d746a2da31ed5d7dc002711a..ee66a9db3336c04533f6dfd46591e08c1259ab9c 100644 (file)
@@ -294,6 +294,7 @@ body .p-datepicker .p-datepicker-header .p-datepicker-title select:focus {
 body .p-datepicker table {
   font-size: 14px;
   margin: 0.857em 0 0 0;
+  table-layout: fixed;
 }
 body .p-datepicker table th {
   padding: 0.5em;
index b0bdb2dd92b3c5a6f7fc118807079659ef8b2381..f09c86d148bbf8c9c83ad55ca49e5c91d7dc50bd 100644 (file)
@@ -38,6 +38,7 @@ export class PlayerManagerOptions {
   private enableApi = false
   private startTime: number | string = 0
   private stopTime: number | string
+  private playbackRate: number | string
 
   private title: boolean
   private warningTitle: boolean
@@ -130,6 +131,7 @@ export class PlayerManagerOptions {
       this.subtitle = getParamString(params, 'subtitle')
       this.startTime = getParamString(params, 'start')
       this.stopTime = getParamString(params, 'stop')
+      this.playbackRate = getParamString(params, 'playbackRate')
 
       this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
       this.foregroundColor = getParamString(params, 'foregroundColor')
@@ -210,6 +212,8 @@ export class PlayerManagerOptions {
           ? playlistTracker.getCurrentElement().stopTimestamp
           : this.stopTime,
 
+        playbackRate: this.playbackRate,
+
         videoCaptions,
         inactivityTimeout: 2500,
         videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
index b680bfdfb083e4e6da82631bdcc56749bd9db39e..6a54562838b5ad0e01cc98dcb9276ac90ad15b4c 100644 (file)
     read-package-json-fast "^2.0.3"
     which "^2.0.2"
 
-"@peertube/p2p-media-loader-core@^1.0.13", "@peertube/p2p-media-loader-core@^1.0.8":
-  version "1.0.13"
-  resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.13.tgz#36744a291b69c001b2562c1a93017979f8534ff8"
-  integrity sha512-ArSAaeuxwwBAG0Xd3Gj0TzKObLfJFYzHz9+fREvmUf+GZQEG6qGwWmrdVWL6xjPiEuo6LdFeCOnHSQzAbj/ptg==
+"@peertube/p2p-media-loader-core@^1.0.14":
+  version "1.0.14"
+  resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.14.tgz#b4442dd343d6b30a51502e1240275eb98ef2c788"
+  integrity sha512-tjQv1CNziNY+zYzcL1h4q6AA2WuBUZnBIeVyjWR/EsO1EEC1VMdvPsL02cqYLz9yvIxgycjeTsWCm6XDqNgXRw==
   dependencies:
     bittorrent-tracker "^9.19.0"
     debug "^4.3.4"
     sha.js "^2.4.11"
     simple-peer "^9.11.1"
 
-"@peertube/p2p-media-loader-hlsjs@^1.0.13":
-  version "1.0.13"
-  resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.13.tgz#5305e2008041d01850802544d1c49298f79dd67a"
-  integrity sha512-2BO2oaRsSHEhLkgi2iw1r4n1Yqq1EnyoOgOZccPDqjmHUsZSV/wNrno8WYr6LsleudrHA26Imu57hVD1jDx7lg==
+"@peertube/p2p-media-loader-hlsjs@^1.0.14":
+  version "1.0.14"
+  resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.14.tgz#829629a57608b0e30f4b50bc98578e6bee9f8b9b"
+  integrity sha512-ySUVgUvAFXCE5E94xxjfywQ8xzk3jy9UGVkgi5Oqq+QeY7uG+o7CZ+LsQ/RjXgWBD70tEnyyfADHtL+9FCnwyQ==
   dependencies:
-    "@peertube/p2p-media-loader-core" "^1.0.8"
+    "@peertube/p2p-media-loader-core" "^1.0.14"
     debug "^4.3.4"
     events "^3.3.0"
     m3u8-parser "^4.7.1"
index 20094ae8fce9cdc7a975361b3ef2005ac7f59ca3..b2c418a0ac4f7926ee4fe0dda26e78923dafab2c 100644 (file)
@@ -37,6 +37,11 @@ rates_limit:
     window: 10 minutes
     max: 10
 
+oauth2:
+  token_lifetime:
+    access_token: '1 day'
+    refresh_token: '2 weeks'
+
 # Proxies to trust to get real client IP
 # If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
 # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
index e8b354d0109332a125db7af7c279cde1617c0045..36fa704178fd75074acb30ffbc440c7d7210f662 100644 (file)
@@ -35,6 +35,11 @@ rates_limit:
     window: 10 minutes
     max: 10
 
+oauth2:
+  token_lifetime:
+    access_token: '1 day'
+    refresh_token: '2 weeks'
+
 # Proxies to trust to get real client IP
 # If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
 # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
index d7d19afc2a3bb9bd663a4351a689eb78d59e071a..b48f65bbd85093bb11f55a6cc252a22e0432671d 100644 (file)
     "swagger-cli": "^4.0.2",
     "ts-node": "^10.8.1",
     "tsc-watch": "^5.0.3",
-    "typescript": "^4.0.5"
+    "typescript": "~4.8"
   },
   "bundlewatch": {
     "files": [
index bcd7fe2a2d3744d02aa1cc9dc97654b9812c4ff7..3b504595462821909b452c0b046ca71ad17442d4 100755 (executable)
@@ -41,6 +41,7 @@ const playerKeys = {
   'Volume': 'Volume',
   'Codecs': 'Codecs',
   'Color': 'Color',
+  'Go back to the live': 'Go back to the live',
   'Connection Speed': 'Connection Speed',
   'Network Activity': 'Network Activity',
   'Total Transfered': 'Total Transfered',
index dd595e9512303e22a6f6e5fb4fcf1f5c0d0f5df2..f6a153fb77e4c091a7cd62acf5623b245740e00f 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -279,7 +279,7 @@ app.use((err, _req, res: express.Response, _next) => {
   })
 })
 
-const server = createWebsocketTrackerServer(app)
+const { server, trackerServer } = createWebsocketTrackerServer(app)
 
 // ----------- Run -----------
 
@@ -328,7 +328,8 @@ async function startApplication () {
   VideoChannelSyncLatestScheduler.Instance.enable()
   VideoViewsBufferScheduler.Instance.enable()
   GeoIPUpdateScheduler.Instance.enable()
-  OpenTelemetryMetrics.Instance.registerMetrics()
+
+  OpenTelemetryMetrics.Instance.registerMetrics({ trackerServer })
 
   PluginManager.Instance.init(server)
   // Before PeerTubeSocket init
index 8e064fb5bc8189759beb7bcc7546e18f8ea6dbc4..def3207300dec8b843e56a0d749235f1ba5048a1 100644 (file)
@@ -309,7 +309,7 @@ async function videoCommentsController (req: express.Request, res: express.Respo
   if (redirectIfNotOwned(video.url, res)) return
 
   const handler = async (start: number, count: number) => {
-    const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count)
+    const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
 
     return {
       total: result.total,
index 44d64776c60a351232c4ea9b3cf547a2f3c28b81..70ca21500a43c2530fd80e2e8a85014f1a012287 100644 (file)
@@ -1,4 +1,6 @@
+import { MCommentFormattable } from '@server/types/models'
 import express from 'express'
+
 import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
 import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
 import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
@@ -109,7 +111,7 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
   const video = res.locals.onlyVideo
   const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
 
-  let resultList: ThreadsResultList<VideoCommentModel>
+  let resultList: ThreadsResultList<MCommentFormattable>
 
   if (video.commentsEnabled === true) {
     const apiOptions = await Hooks.wrapObject({
@@ -144,12 +146,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
   const video = res.locals.onlyVideo
   const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
 
-  let resultList: ResultList<VideoCommentModel>
+  let resultList: ResultList<MCommentFormattable>
 
   if (video.commentsEnabled === true) {
     const apiOptions = await Hooks.wrapObject({
       videoId: video.id,
-      isVideoOwned: video.isOwned(),
       threadId: res.locals.videoCommentThread.id,
       user
     }, 'filter:api.video-thread-comments.list.params')
index 009b6dfb653c56c5ed9a9b93f2ee34006c2d94b1..22387c3e827ae1dc9d5de1f3ca8e923099960dc5 100644 (file)
@@ -22,7 +22,7 @@ export {
 function generateToken (req: express.Request, res: express.Response) {
   const video = res.locals.onlyVideo
 
-  const { token, expires } = VideoTokensManager.Instance.create(video.uuid)
+  const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
 
   return res.json({
     files: {
index 772fe734deda1ebcfb8b7639ab45cf23695010ba..ef810a842fdb79eafed65eb9f4c8ed82381a6d42 100644 (file)
@@ -285,8 +285,8 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
       content: toSafeHtml(video.description),
       author: [
         {
-          name: video.VideoChannel.Account.getDisplayName(),
-          link: video.VideoChannel.Account.Actor.url
+          name: video.VideoChannel.getDisplayName(),
+          link: video.VideoChannel.Actor.url
         }
       ],
       date: video.publishedAt,
index 19a8b2bc937529d999006b7a7a72c8ea7b96ec79..c4f3a8889f75586049a2868298ac5c02f2396802 100644 (file)
@@ -1,17 +1,22 @@
 import { Server as TrackerServer } from 'bittorrent-tracker'
 import express from 'express'
 import { createServer } from 'http'
+import LRUCache from 'lru-cache'
 import proxyAddr from 'proxy-addr'
 import { WebSocketServer } from 'ws'
-import { Redis } from '@server/lib/redis'
 import { logger } from '../helpers/logger'
 import { CONFIG } from '../initializers/config'
-import { TRACKER_RATE_LIMITS } from '../initializers/constants'
+import { LRU_CACHE, TRACKER_RATE_LIMITS } from '../initializers/constants'
 import { VideoFileModel } from '../models/video/video-file'
 import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
 
 const trackerRouter = express.Router()
 
+const blockedIPs = new LRUCache<string, boolean>({
+  max: LRU_CACHE.TRACKER_IPS.MAX_SIZE,
+  ttl: TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME
+})
+
 let peersIps = {}
 let peersIpInfoHash = {}
 runPeersChecker()
@@ -55,8 +60,7 @@ const trackerServer = new TrackerServer({
 
       // Close socket connection and block IP for a few time
       if (params.type === 'ws') {
-        Redis.Instance.setTrackerBlockIP(ip)
-          .catch(err => logger.error('Cannot set tracker block ip.', { err }))
+        blockedIPs.set(ip, true)
 
         // setTimeout to wait filter response
         setTimeout(() => params.socket.close(), 0)
@@ -102,26 +106,22 @@ function createWebsocketTrackerServer (app: express.Application) {
     if (request.url === '/tracker/socket') {
       const ip = proxyAddr(request, CONFIG.TRUST_PROXY)
 
-      Redis.Instance.doesTrackerBlockIPExist(ip)
-        .then(result => {
-          if (result === true) {
-            logger.debug('Blocking IP %s from tracker.', ip)
+      if (blockedIPs.has(ip)) {
+        logger.debug('Blocking IP %s from tracker.', ip)
 
-            socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
-            socket.destroy()
-            return
-          }
+        socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
+        socket.destroy()
+        return
+      }
 
-          // FIXME: typings
-          return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
-        })
-        .catch(err => logger.error('Cannot check if tracker block ip exists.', { err }))
+      // FIXME: typings
+      return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
     }
 
     // Don't destroy socket, we have Socket.IO too
   })
 
-  return server
+  return { server, trackerServer }
 }
 
 // ---------------------------------------------------------------------------
index 3dc5504e32545d611c3b9a1d325621cd6613c87a..b3ab3ac64704edda06955c18d384df3b2e044017 100644 (file)
@@ -103,7 +103,13 @@ function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
 // ---------------------------------------------------------------------------
 
 function toCompleteUUID (value: string) {
-  if (isShortUUID(value)) return shortToUUID(value)
+  if (isShortUUID(value)) {
+    try {
+      return shortToUUID(value)
+    } catch {
+      return null
+    }
+  }
 
   return value
 }
index 59ba005fe3f4b8d8feb152b9e210b559c72feac5..d5b09ea03628a998a921958bb72fec5c20c572b4 100644 (file)
@@ -8,10 +8,11 @@ function isVideoCaptionLanguageValid (value: any) {
   return exists(value) && VIDEO_LANGUAGES[value] !== undefined
 }
 
-const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
-                                .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
-                                .map(m => `(${m})`)
-                                .join('|')
+// MacOS sends application/octet-stream
+const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ]
+  .map(m => `(${m})`)
+  .join('|')
+
 function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
   return isFileValid({
     files,
index af93aea56062a86ca17b1e441c8b9593de123632..da8962cb6dace3b340f3d478fa6adf6ef4df4e0b 100644 (file)
@@ -22,10 +22,11 @@ function isVideoImportStateValid (value: any) {
   return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined
 }
 
-const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT)
-                                      .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
-                                      .map(m => `(${m})`)
-                                      .join('|')
+// MacOS sends application/octet-stream
+const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ]
+  .map(m => `(${m})`)
+  .join('|')
+
 function isVideoImportTorrentFile (files: UploadFilesForCheck) {
   return isFileValid({
     files,
index e31973b7a61cb5b00b1c7c2c0bb729f1e1a7d8ac..08ab545e42aab664d479bdf87163467f98cbc74d 100644 (file)
@@ -68,7 +68,7 @@ function searchCache (moduleName: string, callback: (current: NodeModule) => voi
 };
 
 function removeCachedPath (pluginPath: string) {
-  const pathCache = (module.constructor as any)._pathCache
+  const pathCache = (module.constructor as any)._pathCache as { [ id: string ]: string[] }
 
   Object.keys(pathCache).forEach(function (cacheKey) {
     if (cacheKey.includes(pluginPath)) {
diff --git a/server/helpers/memoize.ts b/server/helpers/memoize.ts
new file mode 100644 (file)
index 0000000..aa20e7d
--- /dev/null
@@ -0,0 +1,12 @@
+import memoizee from 'memoizee'
+
+export function Memoize (config?: memoizee.Options<any>) {
+  return function (_target, _key, descriptor: PropertyDescriptor) {
+    const oldFunction = descriptor.value
+    const newFunction = memoizee(oldFunction, config)
+
+    descriptor.value = function () {
+      return newFunction.apply(this, arguments)
+    }
+  }
+}
index a2f63095378e2c57b3d876cd697384d8561cc025..765038cea29cd9e6b9ebee0e1663c81f1422aa12 100644 (file)
@@ -6,6 +6,7 @@ import { VideoResolution } from '@shared/models'
 import { logger, loggerTagsFactory } from '../logger'
 import { getProxy, isProxyEnabled } from '../proxy'
 import { isBinaryResponse, peertubeGot } from '../requests'
+import { OptionsOfBufferResponseBody } from 'got/dist/source'
 
 const lTags = loggerTagsFactory('youtube-dl')
 
@@ -28,7 +29,16 @@ export class YoutubeDLCLI {
 
     logger.info('Updating youtubeDL binary from %s.', url, lTags())
 
-    const gotOptions = { context: { bodyKBLimit: 20_000 }, responseType: 'buffer' as 'buffer' }
+    const gotOptions: OptionsOfBufferResponseBody = {
+      context: { bodyKBLimit: 20_000 },
+      responseType: 'buffer' as 'buffer'
+    }
+
+    if (process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN) {
+      gotOptions.headers = {
+        authorization: 'Bearer ' + process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN
+      }
+    }
 
     try {
       let gotResult = await peertubeGot(url, gotOptions)
index c83fef425af8fc4ddd46ade9745000e54fff27e3..dc46b5126d2865dbe0eb1b0fc05de053f4aebb7d 100644 (file)
@@ -174,7 +174,8 @@ function checkRemoteRedundancyConfig () {
 function checkStorageConfig () {
   // Check storage directory locations
   if (isProdInstance()) {
-    const configStorage = config.get('storage')
+    const configStorage = config.get<{ [ name: string ]: string }>('storage')
+
     for (const key of Object.keys(configStorage)) {
       if (configStorage[key].startsWith('storage/')) {
         logger.warn(
index 39713a26678692035b653239dddae1627dfbfd74..57852241c2fa6927147f838bc964eeef35c28d51 100644 (file)
@@ -13,6 +13,7 @@ function checkMissedConfig () {
     'webserver.https', 'webserver.hostname', 'webserver.port',
     'secrets.peertube',
     'trust_proxy',
+    'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token',
     'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
     'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
     'email.body.signature', 'email.subject.prefix',
index c2f8b19fd60278cf579e54f5680933f33348e5d2..28aaf36a974dd2432c775e823f6ac2f731a22ded 100644 (file)
@@ -149,6 +149,12 @@ const CONFIG = {
     HOSTNAME: config.get<string>('webserver.hostname'),
     PORT: config.get<number>('webserver.port')
   },
+  OAUTH2: {
+    TOKEN_LIFETIME: {
+      ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')),
+      REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token'))
+    }
+  },
   RATES_LIMIT: {
     API: {
       WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')),
index 0e56f0c9f7cc6b04de3d18c8db103c532fafd04a..0dab524d9cef3d4a07408f10d73e17927b3c1e37 100644 (file)
@@ -101,11 +101,6 @@ const SORTABLE_COLUMNS = {
   VIDEO_REDUNDANCIES: [ 'name' ]
 }
 
-const OAUTH_LIFETIME = {
-  ACCESS_TOKEN: 3600 * 24, // 1 day, for upload
-  REFRESH_TOKEN: 1209600 // 2 weeks
-}
-
 const ROUTE_CACHE_LIFETIME = {
   FEEDS: '15 minutes',
   ROBOTS: '2 hours',
@@ -781,6 +776,9 @@ const LRU_CACHE = {
   VIDEO_TOKENS: {
     MAX_SIZE: 100_000,
     TTL: parseDurationToMs('8 hours')
+  },
+  TRACKER_IPS: {
+    MAX_SIZE: 100_000
   }
 }
 
@@ -884,7 +882,7 @@ const TRACKER_RATE_LIMITS = {
   INTERVAL: 60000 * 5, // 5 minutes
   ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval
   ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval
-  BLOCK_IP_LIFETIME: 60000 * 3 // 3 minutes
+  BLOCK_IP_LIFETIME: parseDurationToMs('3 minutes')
 }
 
 const P2P_MEDIA_LOADER_PEER_VERSION = 2
@@ -1030,7 +1028,6 @@ export {
   JOB_ATTEMPTS,
   AP_CLEANER,
   LAST_MIGRATION_VERSION,
-  OAUTH_LIFETIME,
   CUSTOM_HTML_TAG_COMMENTS,
   STATS_TIMESERIE,
   BROADCAST_CONCURRENCY,
index f5d8eedf1916a687333ebb118093b6f328466083..f48f348a7bc118a91937b774b7eb1ae4fc92ae46 100644 (file)
@@ -51,8 +51,7 @@ function removeCacheAndTmpDirectories () {
   const tasks: Promise<any>[] = []
 
   // Cache directories
-  for (const key of Object.keys(cacheDirectories)) {
-    const dir = cacheDirectories[key]
+  for (const dir of cacheDirectories) {
     tasks.push(removeDirectoryOrContent(dir))
   }
 
@@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () {
   }
 
   // Cache directories
-  for (const key of Object.keys(cacheDirectories)) {
-    const dir = cacheDirectories[key]
+  for (const dir of cacheDirectories) {
     tasks.push(ensureDir(dir))
   }
 
index 0531128016afbe7b8e8b51858daf80aa433328ce..bc5b74257a2a12c559040c5de8690803a2f4e0b4 100644 (file)
@@ -1,26 +1,35 @@
 
-import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users'
+import {
+  isUserAdminFlagsValid,
+  isUserDisplayNameValid,
+  isUserRoleValid,
+  isUserUsernameValid,
+  isUserVideoQuotaDailyValid,
+  isUserVideoQuotaValid
+} from '@server/helpers/custom-validators/users'
 import { logger } from '@server/helpers/logger'
 import { generateRandomString } from '@server/helpers/utils'
 import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
 import { PluginManager } from '@server/lib/plugins/plugin-manager'
 import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
+import { MUser } from '@server/types/models'
 import {
   RegisterServerAuthenticatedResult,
   RegisterServerAuthPassOptions,
   RegisterServerExternalAuthenticatedResult
 } from '@server/types/plugins/register-server-auth.model'
-import { UserRole } from '@shared/models'
+import { UserAdminFlag, UserRole } from '@shared/models'
+import { BypassLogin } from './oauth-model'
+
+export type ExternalUser =
+  Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
+  { displayName: string }
 
 // Token is the key, expiration date is the value
 const authBypassTokens = new Map<string, {
   expires: Date
-  user: {
-    username: string
-    email: string
-    displayName: string
-    role: UserRole
-  }
+  user: ExternalUser
+  userUpdater: RegisterServerAuthenticatedResult['userUpdater']
   authName: string
   npmName: string
 }>()
@@ -56,7 +65,8 @@ async function onExternalUserAuthenticated (options: {
     expires,
     user,
     npmName,
-    authName
+    authName,
+    userUpdater: authResult.userUpdater
   })
 
   // Cleanup expired tokens
@@ -78,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) {
   return tokenModel?.authName
 }
 
-async function getBypassFromPasswordGrant (username: string, password: string) {
+async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
   const plugins = PluginManager.Instance.getIdAndPassAuths()
   const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
 
@@ -133,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
         bypass: true,
         pluginName: pluginAuth.npmName,
         authName: authOptions.authName,
-        user: buildUserResult(loginResult)
+        user: buildUserResult(loginResult),
+        userUpdater: loginResult.userUpdater
       }
     } catch (err) {
       logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
@@ -143,7 +154,7 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
   return undefined
 }
 
-function getBypassFromExternalAuth (username: string, externalAuthToken: string) {
+function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
   const obj = authBypassTokens.get(externalAuthToken)
   if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
 
@@ -167,33 +178,29 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string)
     bypass: true,
     pluginName: npmName,
     authName,
+    userUpdater: obj.userUpdater,
     user
   }
 }
 
 function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
-  if (!isUserUsernameValid(result.username)) {
-    logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username })
+  const returnError = (field: string) => {
+    logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
     return false
   }
 
-  if (!result.email) {
-    logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email })
-    return false
-  }
+  if (!isUserUsernameValid(result.username)) return returnError('username')
+  if (!result.email) return returnError('email')
 
-  // role is optional
-  if (result.role && !isUserRoleValid(result.role)) {
-    logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role })
-    return false
-  }
+  // Following fields are optional
+  if (result.role && !isUserRoleValid(result.role)) return returnError('role')
+  if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
+  if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
+  if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
+  if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
 
-  // display name is optional
-  if (result.displayName && !isUserDisplayNameValid(result.displayName)) {
-    logger.error(
-      'Auth method %s of plugin %s did not provide a valid display name.',
-      authName, npmName, { displayName: result.displayName }
-    )
+  if (result.userUpdater && typeof result.userUpdater !== 'function') {
+    logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
     return false
   }
 
@@ -205,7 +212,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
     username: pluginResult.username,
     email: pluginResult.email,
     role: pluginResult.role ?? UserRole.USER,
-    displayName: pluginResult.displayName || pluginResult.username
+    displayName: pluginResult.displayName || pluginResult.username,
+
+    adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
+
+    videoQuota: pluginResult.videoQuota,
+    videoQuotaDaily: pluginResult.videoQuotaDaily
   }
 }
 
index 322b69e3a9feea712138b355310f0c5becfee354..43909284f8c10b283d36bc490110b240ff5f7da4 100644 (file)
@@ -1,11 +1,13 @@
 import express from 'express'
 import { AccessDeniedError } from '@node-oauth/oauth2-server'
 import { PluginManager } from '@server/lib/plugins/plugin-manager'
+import { AccountModel } from '@server/models/account/account'
+import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types'
 import { MOAuthClient } from '@server/types/models'
 import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
-import { MUser } from '@server/types/models/user/user'
+import { MUser, MUserDefault } from '@server/types/models/user/user'
 import { pick } from '@shared/core-utils'
-import { UserRole } from '@shared/models/users/user-role'
+import { AttributesOnly } from '@shared/typescript-utils'
 import { logger } from '../../helpers/logger'
 import { CONFIG } from '../../initializers/config'
 import { OAuthClientModel } from '../../models/oauth/oauth-client'
@@ -13,6 +15,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token'
 import { UserModel } from '../../models/user/user'
 import { findAvailableLocalActorName } from '../local-actor'
 import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
+import { ExternalUser } from './external-auth'
 import { TokensCache } from './tokens-cache'
 
 type TokenInfo = {
@@ -26,12 +29,8 @@ export type BypassLogin = {
   bypass: boolean
   pluginName: string
   authName?: string
-  user: {
-    username: string
-    email: string
-    displayName: string
-    role: UserRole
-  }
+  user: ExternalUser
+  userUpdater: RegisterServerAuthenticatedResult['userUpdater']
 }
 
 async function getAccessToken (bearerToken: string) {
@@ -89,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
     logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
 
     let user = await UserModel.loadByEmail(bypassLogin.user.email)
+
     if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
+    else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
 
     // Cannot create a user
     if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
@@ -219,16 +220,11 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function createUserFromExternal (pluginAuth: string, options: {
-  username: string
-  email: string
-  role: UserRole
-  displayName: string
-}) {
-  const username = await findAvailableLocalActorName(options.username)
+async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
+  const username = await findAvailableLocalActorName(userOptions.username)
 
   const userToCreate = buildUser({
-    ...pick(options, [ 'email', 'role' ]),
+    ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
 
     username,
     emailVerified: null,
@@ -238,12 +234,57 @@ async function createUserFromExternal (pluginAuth: string, options: {
 
   const { user } = await createUserAccountAndChannelAndPlaylist({
     userToCreate,
-    userDisplayName: options.displayName
+    userDisplayName: userOptions.displayName
   })
 
   return user
 }
 
+async function updateUserFromExternal (
+  user: MUserDefault,
+  userOptions: ExternalUser,
+  userUpdater: RegisterServerAuthenticatedResult['userUpdater']
+) {
+  if (!userUpdater) return user
+
+  {
+    type UserAttributeKeys = keyof AttributesOnly<UserModel>
+    const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
+      role: 'role',
+      adminFlags: 'adminFlags',
+      videoQuota: 'videoQuota',
+      videoQuotaDaily: 'videoQuotaDaily'
+    }
+
+    for (const modelKey of Object.keys(mappingKeys)) {
+      const pluginOptionKey = mappingKeys[modelKey]
+
+      const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
+      user.set(modelKey, newValue)
+    }
+  }
+
+  {
+    type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
+    const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
+      name: 'displayName'
+    }
+
+    for (const modelKey of Object.keys(mappingKeys)) {
+      const optionKey = mappingKeys[modelKey]
+
+      const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
+      user.Account.set(modelKey, newValue)
+    }
+  }
+
+  logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
+
+  user.Account = await user.Account.save()
+
+  return user.save()
+}
+
 function checkUserValidityOrThrow (user: MUser) {
   if (user.blocked) throw new AccessDeniedError('User is blocked.')
 }
index bc0d4301f082519a02a666d279c0e6bf5ad8c431..2905c79a21ea9df187b6bd99b9fe07adbe185c65 100644 (file)
@@ -10,10 +10,11 @@ import OAuth2Server, {
 } from '@node-oauth/oauth2-server'
 import { randomBytesPromise } from '@server/helpers/core-utils'
 import { isOTPValid } from '@server/helpers/otp'
+import { CONFIG } from '@server/initializers/config'
 import { MOAuthClient } from '@server/types/models'
 import { sha1 } from '@shared/extra-utils'
 import { HttpStatusCode } from '@shared/models'
-import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
+import { OTP } from '../../initializers/constants'
 import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
 
 class MissingTwoFactorError extends Error {
@@ -32,8 +33,9 @@ class InvalidTwoFactorError extends Error {
  *
  */
 const oAuthServer = new OAuth2Server({
-  accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN,
-  refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN,
+  // Wants seconds
+  accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
+  refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
 
   // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
   model: require('./oauth-model')
@@ -182,10 +184,10 @@ function generateRandomToken () {
 
 function getTokenExpiresAt (type: 'access' | 'refresh') {
   const lifetime = type === 'access'
-    ? OAUTH_LIFETIME.ACCESS_TOKEN
-    : OAUTH_LIFETIME.REFRESH_TOKEN
+    ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
+    : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
 
-  return new Date(Date.now() + lifetime * 1000)
+  return new Date(Date.now() + lifetime)
 }
 
 async function buildToken () {
index 410708a352e325bff62db53beca2689548384867..43efc7d02bd8aec815afd4eea7ebbd57a2b8875d 100644 (file)
@@ -36,8 +36,8 @@ export class TokensCache {
     const token = this.userHavingToken.get(userId)
 
     if (token !== undefined) {
-      this.accessTokenCache.del(token)
-      this.userHavingToken.del(userId)
+      this.accessTokenCache.delete(token)
+      this.userHavingToken.delete(userId)
     }
   }
 
@@ -45,8 +45,8 @@ export class TokensCache {
     const tokenModel = this.accessTokenCache.get(token)
 
     if (tokenModel !== undefined) {
-      this.userHavingToken.del(tokenModel.userId)
-      this.accessTokenCache.del(token)
+      this.userHavingToken.delete(tokenModel.userId)
+      this.accessTokenCache.delete(token)
     }
   }
 }
index 866aa1ed0eef876a9e45b719f3b12e753ec9e450..8597eb00018356dc081237b0f48f57cb7f8d6415 100644 (file)
@@ -184,7 +184,7 @@ class JobQueue {
 
     this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST
 
-    for (const handlerName of (Object.keys(handlers) as JobType[])) {
+    for (const handlerName of Object.keys(handlers)) {
       this.buildWorker(handlerName)
       this.buildQueue(handlerName)
       this.buildQueueScheduler(handlerName)
diff --git a/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
new file mode 100644 (file)
index 0000000..ef40c0f
--- /dev/null
@@ -0,0 +1,51 @@
+import { Meter } from '@opentelemetry/api'
+
+export class BittorrentTrackerObserversBuilder {
+
+  constructor (private readonly meter: Meter, private readonly trackerServer: any) {
+
+  }
+
+  buildObservers () {
+    const activeInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_active_infohashes_total', {
+      description: 'Total active infohashes in the PeerTube BitTorrent Tracker'
+    })
+    const inactiveInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_inactive_infohashes_total', {
+      description: 'Total inactive infohashes in the PeerTube BitTorrent Tracker'
+    })
+    const peers = this.meter.createObservableGauge('peertube_bittorrent_tracker_peers_total', {
+      description: 'Total peers in the PeerTube BitTorrent Tracker'
+    })
+
+    this.meter.addBatchObservableCallback(observableResult => {
+      const infohashes = Object.keys(this.trackerServer.torrents)
+
+      const counters = {
+        activeInfohashes: 0,
+        inactiveInfohashes: 0,
+        peers: 0,
+        uncompletedPeers: 0
+      }
+
+      for (const infohash of infohashes) {
+        const content = this.trackerServer.torrents[infohash]
+
+        const peers = content.peers
+        if (peers.keys.length !== 0) counters.activeInfohashes++
+        else counters.inactiveInfohashes++
+
+        for (const peerId of peers.keys) {
+          const peer = peers.peek(peerId)
+          if (peer == null) return
+
+          counters.peers++
+        }
+      }
+
+      observableResult.observe(activeInfohashes, counters.activeInfohashes)
+      observableResult.observe(inactiveInfohashes, counters.inactiveInfohashes)
+      observableResult.observe(peers, counters.peers)
+    }, [ activeInfohashes, inactiveInfohashes, peers ])
+  }
+
+}
index 775d954ba1e02349eb1e674aa9dbac6ec6c7b1cd..47b24a54f197065c0615746c81eca1dc1da13779 100644 (file)
@@ -1,3 +1,4 @@
+export * from './bittorrent-tracker-observers-builder'
 export * from './lives-observers-builder'
 export * from './job-queue-observers-builder'
 export * from './nodejs-observers-builder'
index 226d514c0634c468569106d81c0325026717d992..9cc067e4ab047ec6cae54dfb017f794f355c3609 100644 (file)
@@ -7,6 +7,7 @@ import { CONFIG } from '@server/initializers/config'
 import { MVideoImmutable } from '@server/types/models'
 import { PlaybackMetricCreate } from '@shared/models'
 import {
+  BittorrentTrackerObserversBuilder,
   JobQueueObserversBuilder,
   LivesObserversBuilder,
   NodeJSObserversBuilder,
@@ -41,7 +42,7 @@ class OpenTelemetryMetrics {
     })
   }
 
-  registerMetrics () {
+  registerMetrics (options: { trackerServer: any }) {
     if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return
 
     logger.info('Registering Open Telemetry metrics')
@@ -80,6 +81,9 @@ class OpenTelemetryMetrics {
 
     const viewersObserversBuilder = new ViewersObserversBuilder(this.meter)
     viewersObserversBuilder.buildObservers()
+
+    const bittorrentTrackerObserversBuilder = new BittorrentTrackerObserversBuilder(this.meter, options.trackerServer)
+    bittorrentTrackerObserversBuilder.buildObservers()
   }
 
   observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
index 7b1def6e30c9e171f8703f4751047374f8fbbfed..66383af46a0b462e497dd9120d13e6b409b92d30 100644 (file)
@@ -209,6 +209,10 @@ function buildConfigHelpers () {
       return WEBSERVER.URL
     },
 
+    getServerListeningConfig () {
+      return { hostname: CONFIG.LISTEN.HOSTNAME, port: CONFIG.LISTEN.PORT }
+    },
+
     getServerConfig () {
       return ServerConfigManager.Instance.getServerConfig()
     }
@@ -245,7 +249,7 @@ function buildUserHelpers () {
     },
 
     getAuthUser: (res: express.Response) => {
-      const user = res.locals.oauth?.token?.User
+      const user = res.locals.oauth?.token?.User || res.locals.videoFileToken?.user
       if (!user) return undefined
 
       return UserModel.loadByIdFull(user.id)
index c0e9aece747307580e4bc3214b09118ecc2071c3..451ddd0b6d8d837afc9350001435702e396e6d61 100644 (file)
@@ -8,7 +8,6 @@ import {
   AP_CLEANER,
   CONTACT_FORM_LIFETIME,
   RESUMABLE_UPLOAD_SESSION_LIFETIME,
-  TRACKER_RATE_LIMITS,
   TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
   USER_EMAIL_VERIFY_LIFETIME,
   USER_PASSWORD_CREATE_LIFETIME,
@@ -157,16 +156,6 @@ class Redis {
     return this.exists(this.generateIPViewKey(ip, videoUUID))
   }
 
-  /* ************ Tracker IP block ************ */
-
-  setTrackerBlockIP (ip: string) {
-    return this.setValue(this.generateTrackerBlockIPKey(ip), '1', TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME)
-  }
-
-  async doesTrackerBlockIPExist (ip: string) {
-    return this.exists(this.generateTrackerBlockIPKey(ip))
-  }
-
   /* ************ Video views stats ************ */
 
   addVideoViewStats (videoId: number) {
@@ -365,10 +354,6 @@ class Redis {
     return `views-${videoUUID}-${ip}`
   }
 
-  private generateTrackerBlockIPKey (ip: string) {
-    return `tracker-block-ip-${ip}`
-  }
-
   private generateContactFormKey (ip: string) {
     return 'contact-form-' + ip
   }
index 10167ee38edf1afe80ef75dbbd1bac7694e71835..3a805a943d6ab5c01271c37807ae54cf4d0ac58e 100644 (file)
@@ -76,7 +76,7 @@ export async function synchronizeChannel (options: {
 
     await JobQueue.Instance.createJobWithChildren(parent, children)
   } catch (err) {
-    logger.error(`Failed to import channel ${channel.name}`, { err })
+    logger.error(`Failed to import ${externalChannelUrl} in channel ${channel.name}`, { err })
     channelSync.state = VideoChannelSyncState.FAILED
     await channelSync.save()
   }
index 02f160fe833f0970932462b9b7466551f55900d1..6eb865f7f97793c88540c841901a28e88aed128a 100644 (file)
@@ -1,30 +1,41 @@
+import express from 'express'
 import { cloneDeep } from 'lodash'
 import * as Sequelize from 'sequelize'
-import express from 'express'
 import { logger } from '@server/helpers/logger'
 import { sequelizeTypescript } from '@server/initializers/database'
 import { ResultList } from '../../shared/models'
 import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
 import { VideoCommentModel } from '../models/video/video-comment'
-import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models'
+import {
+  MAccountDefault,
+  MComment,
+  MCommentFormattable,
+  MCommentOwnerVideo,
+  MCommentOwnerVideoReply,
+  MVideoFullLight
+} from '../types/models'
 import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
 import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
 import { Hooks } from './plugins/hooks'
 
-async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) {
-  const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
+async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
+  let videoCommentInstanceBefore: MCommentOwnerVideo
 
   await sequelizeTypescript.transaction(async t => {
-    if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
-      await sendDeleteVideoComment(videoCommentInstance, t)
+    const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
+
+    videoCommentInstanceBefore = cloneDeep(comment)
+
+    if (comment.isOwned() || comment.Video.isOwned()) {
+      await sendDeleteVideoComment(comment, t)
     }
 
-    videoCommentInstance.markAsDeleted()
+    comment.markAsDeleted()
 
-    await videoCommentInstance.save({ transaction: t })
-  })
+    await comment.save({ transaction: t })
 
-  logger.info('Video comment %d deleted.', videoCommentInstance.id)
+    logger.info('Video comment %d deleted.', comment.id)
+  })
 
   Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
 }
@@ -64,7 +75,7 @@ async function createVideoComment (obj: {
   return savedComment
 }
 
-function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree {
+function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
   // Comments are sorted by id ASC
   const comments = resultList.data
 
index c43085d167631d5bf08956a31c20aceb7dda922f..17aa29cdda707f9dc2e3893557d3e8aae3d389aa 100644 (file)
@@ -1,5 +1,7 @@
 import LRUCache from 'lru-cache'
 import { LRU_CACHE } from '@server/initializers/constants'
+import { MUserAccountUrl } from '@server/types/models'
+import { pick } from '@shared/core-utils'
 import { buildUUID } from '@shared/extra-utils'
 
 // ---------------------------------------------------------------------------
@@ -10,19 +12,22 @@ class VideoTokensManager {
 
   private static instance: VideoTokensManager
 
-  private readonly lruCache = new LRUCache<string, string>({
+  private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({
     max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
     ttl: LRU_CACHE.VIDEO_TOKENS.TTL
   })
 
   private constructor () {}
 
-  create (videoUUID: string) {
+  create (options: {
+    user: MUserAccountUrl
+    videoUUID: string
+  }) {
     const token = buildUUID()
 
     const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
 
-    this.lruCache.set(token, videoUUID)
+    this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
 
     return { token, expires }
   }
@@ -34,7 +39,16 @@ class VideoTokensManager {
     const value = this.lruCache.get(options.token)
     if (!value) return false
 
-    return value === options.videoUUID
+    return value.videoUUID === options.videoUUID
+  }
+
+  getUserFromToken (options: {
+    token: string
+  }) {
+    const value = this.lruCache.get(options.token)
+    if (!value) return undefined
+
+    return value.user
   }
 
   static get Instance () {
index 4588958988af88a759c35ebfbc7a6afb58ed0643..77a532276d26de286b44ccb7c8700d82a4270504 100644 (file)
@@ -1,5 +1,4 @@
 import express from 'express'
-import { SortType } from '../models/utils'
 
 const setDefaultSort = setDefaultSortFactory('-createdAt')
 const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
@@ -7,27 +6,7 @@ const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
 const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
 
 const setDefaultSearchSort = setDefaultSortFactory('-match')
-
-function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
-  const newSort: SortType = { sortModel: undefined, sortValue: '' }
-
-  if (!req.query.sort) req.query.sort = '-createdAt'
-
-  // Set model we want to sort onto
-  if (req.query.sort === '-createdAt' || req.query.sort === 'createdAt' ||
-      req.query.sort === '-id' || req.query.sort === 'id') {
-    // If we want to sort onto the BlacklistedVideos relation, we won't specify it in the query parameter...
-    newSort.sortModel = undefined
-  } else {
-    newSort.sortModel = 'Video'
-  }
-
-  newSort.sortValue = req.query.sort
-
-  req.query.sort = newSort
-
-  return next()
-}
+const setBlacklistSort = setDefaultSortFactory('-createdAt')
 
 // ---------------------------------------------------------------------------
 
index ebbfc0a0a9242232c349dd070c612b4ac5d51549..0033a32ff1af454f6a7794ce3403e49965b5526a 100644 (file)
@@ -180,18 +180,16 @@ async function checkCanAccessVideoStaticFiles (options: {
     return checkCanSeeVideo(options)
   }
 
-  if (!video.hasPrivateStaticPath()) return true
-
   const videoFileToken = req.query.videoFileToken
-  if (!videoFileToken) {
-    res.sendStatus(HttpStatusCode.FORBIDDEN_403)
-    return false
-  }
+  if (videoFileToken && VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
+    const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
 
-  if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
+    res.locals.videoFileToken = { user }
     return true
   }
 
+  if (!video.hasPrivateStaticPath()) return true
+
   res.sendStatus(HttpStatusCode.FORBIDDEN_403)
   return false
 }
index 20008768be91dfbfa2936e4ba75e8f836050403e..14a5bffa28707fd0c9e28bc05c225d83339d57ce 100644 (file)
@@ -5,7 +5,7 @@ import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
 import { AbuseMessage } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 import { AbuseModel } from './abuse'
 
 @Table({
index 4c6a96a86d3f882c0afd06ca5035aa2118d8f3d9..4ce40bf2f389736210d25f05306cb86e4447447e 100644 (file)
@@ -34,13 +34,13 @@ import { AttributesOnly } from '@shared/typescript-utils'
 import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
 import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 import { ThumbnailModel } from '../video/thumbnail'
 import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
 import { VideoBlacklistModel } from '../video/video-blacklist'
 import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
 import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
-import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder'
+import { buildAbuseListQuery, BuildAbusesQueryOptions } from './sql/abuse-query-builder'
 import { VideoAbuseModel } from './video-abuse'
 import { VideoCommentAbuseModel } from './video-comment-abuse'
 
similarity index 97%
rename from server/models/abuse/abuse-query-builder.ts
rename to server/models/abuse/sql/abuse-query-builder.ts
index 74f4542e55259ff20a47e94bb1e080de3f094ced..282d4541a3e4c4a6cdee706b4087d8832dc4822a 100644 (file)
@@ -2,7 +2,7 @@
 import { exists } from '@server/helpers/custom-validators/misc'
 import { forceNumber } from '@shared/core-utils'
 import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
-import { buildBlockedAccountSQL, buildDirectionAndField } from '../utils'
+import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared'
 
 export type BuildAbusesQueryOptions = {
   start: number
@@ -157,7 +157,7 @@ function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' |
 }
 
 function buildAbuseOrder (value: string) {
-  const { direction, field } = buildDirectionAndField(value)
+  const { direction, field } = buildSortDirectionAndField(value)
 
   return `ORDER BY "abuse"."${field}" ${direction}`
 }
index 377249b38608ba565a9270c1a814cb202bf54899..f6212ff6e9a11e43c5ca8bb5b7f58ac44b7f7135 100644 (file)
@@ -6,7 +6,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
 import { AccountBlock } from '../../../shared/models'
 import { ActorModel } from '../actor/actor'
 import { ServerModel } from '../server/server'
-import { createSafeIn, getSort, searchAttribute } from '../utils'
+import { createSafeIn, getSort, searchAttribute } from '../shared'
 import { AccountModel } from './account'
 
 @Table({
index 7afc907da2b7c9a47ac606dae2e1afd888c1d406..9e7ef4394b07b20695896572f31ec44f5528f269 100644 (file)
@@ -11,7 +11,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
 import { ActorModel } from '../actor/actor'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
 import { AccountModel } from './account'
index 8a7dfba9454d1327dc90648218eaee6728df5018..dc989417bf2ab4c9aea8020e3127584e645c90d4 100644 (file)
@@ -16,7 +16,7 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { Account, AccountSummary } from '../../../shared/models/actors'
 import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
@@ -38,7 +38,7 @@ import { ApplicationModel } from '../application/application'
 import { ServerModel } from '../server/server'
 import { ServerBlocklistModel } from '../server/server-blocklist'
 import { UserModel } from '../user/user'
-import { getSort, throwIfNotValid } from '../utils'
+import { buildSQLAttributes, getSort, throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { VideoChannelModel } from '../video/video-channel'
 import { VideoCommentModel } from '../video/video-comment'
@@ -251,6 +251,18 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
     return undefined
   }
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static load (id: number, transaction?: Transaction): Promise<MAccountDefault> {
     return AccountModel.findByPk(id, { transaction })
   }
index 9615229dd7ea3a332262e3f2a31951e42d29ab7f..32e5d78b0b2fa61c252bcaf02df0c8bbd0151e18 100644 (file)
@@ -38,7 +38,7 @@ import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAM
 import { AccountModel } from '../account/account'
 import { ServerModel } from '../server/server'
 import { doesExist } from '../shared/query'
-import { createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../utils'
+import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared'
 import { VideoChannelModel } from '../video/video-channel'
 import { ActorModel, unusedActorAttributesForAPI } from './actor'
 import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder'
@@ -140,6 +140,18 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
     })
   }
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   /*
    * @deprecated Use `findOrCreateCustom` instead
   */
@@ -213,7 +225,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
       `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
       `LIMIT 1`
 
-    return doesExist(query, { actorId, followerActorId })
+    return doesExist(this.sequelize, query, { actorId, followerActorId })
   }
 
   static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
index f2b3b2f4b33672052c05658d138e101526d14768..9c34a0101acf46c778f548933f7ed924d756d5a0 100644 (file)
@@ -22,7 +22,7 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
 import { logger } from '../../helpers/logger'
 import { CONFIG } from '../../initializers/config'
 import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
-import { throwIfNotValid } from '../utils'
+import { buildSQLAttributes, throwIfNotValid } from '../shared'
 import { ActorModel } from './actor'
 
 @Table({
@@ -94,6 +94,18 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
       .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err }))
   }
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static loadByName (filename: string) {
     const query = {
       where: {
index d7afa727d2e50185f4d4ef817f287c0ac3174e22..1432e87574468e493a8d9ec3e225c4c6b927e4dd 100644 (file)
@@ -17,7 +17,7 @@ import {
 } from 'sequelize-typescript'
 import { activityPubContextify } from '@server/lib/activitypub/context'
 import { getBiggestActorImage } from '@server/lib/actor-image'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
 import { forceNumber, getLowercaseExtension } from '@shared/core-utils'
 import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
@@ -55,7 +55,7 @@ import {
 import { AccountModel } from '../account/account'
 import { getServerActor } from '../application/application'
 import { ServerModel } from '../server/server'
-import { isOutdated, throwIfNotValid } from '../utils'
+import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { VideoChannelModel } from '../video/video-channel'
 import { ActorFollowModel } from './actor-follow'
@@ -65,7 +65,7 @@ enum ScopeNames {
   FULL = 'FULL'
 }
 
-export const unusedActorAttributesForAPI = [
+export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] = [
   'publicKey',
   'privateKey',
   'inboxUrl',
@@ -306,6 +306,27 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
   })
   VideoChannel: VideoChannelModel
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix,
+      excludeAttributes: unusedActorAttributesForAPI
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static async load (id: number): Promise<MActor> {
     const actorServer = await getServerActor()
     if (id === actorServer.id) return actorServer
index 4a17a8f11213a23bef7dd252da6a58226bb2b35e..34ce29b5dcacb7edddcc5dd0b8b3d2dc881c4ccd 100644 (file)
@@ -1,8 +1,8 @@
 import { Sequelize } from 'sequelize'
 import { ModelBuilder } from '@server/models/shared'
-import { parseRowCountResult } from '@server/models/utils'
 import { MActorFollowActorsDefault } from '@server/types/models'
 import { ActivityPubActorType, FollowState } from '@shared/models'
+import { parseRowCountResult } from '../../shared'
 import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
 
 export interface ListFollowersOptions {
index 880170b857db4c7f34ade557fc604cfea3324e53..77b4e3dce2599b82487c8fa74718107f62d8f266 100644 (file)
@@ -1,8 +1,8 @@
 import { Sequelize } from 'sequelize'
 import { ModelBuilder } from '@server/models/shared'
-import { parseRowCountResult } from '@server/models/utils'
 import { MActorFollowActorsDefault } from '@server/types/models'
 import { ActivityPubActorType, FollowState } from '@shared/models'
+import { parseRowCountResult } from '../../shared'
 import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
 
 export interface ListFollowingOptions {
index 156b37d44d56d77f7cdf68d8166c3d0307076b3e..7dd908ece9b12bab51f41bbb7fe5879ab753b9e3 100644 (file)
@@ -1,62 +1,31 @@
+import { logger } from '@server/helpers/logger'
+import { Memoize } from '@server/helpers/memoize'
+import { ServerModel } from '@server/models/server/server'
+import { ActorModel } from '../../actor'
+import { ActorFollowModel } from '../../actor-follow'
+import { ActorImageModel } from '../../actor-image'
+
 export class ActorFollowTableAttributes {
 
+  @Memoize()
   getFollowAttributes () {
-    return [
-      '"ActorFollowModel"."id"',
-      '"ActorFollowModel"."state"',
-      '"ActorFollowModel"."score"',
-      '"ActorFollowModel"."url"',
-      '"ActorFollowModel"."actorId"',
-      '"ActorFollowModel"."targetActorId"',
-      '"ActorFollowModel"."createdAt"',
-      '"ActorFollowModel"."updatedAt"'
-    ].join(', ')
+    logger.error('coucou')
+
+    return ActorFollowModel.getSQLAttributes('ActorFollowModel').join(', ')
   }
 
+  @Memoize()
   getActorAttributes (actorTableName: string) {
-    return [
-      `"${actorTableName}"."id" AS "${actorTableName}.id"`,
-      `"${actorTableName}"."type" AS "${actorTableName}.type"`,
-      `"${actorTableName}"."preferredUsername" AS "${actorTableName}.preferredUsername"`,
-      `"${actorTableName}"."url" AS "${actorTableName}.url"`,
-      `"${actorTableName}"."publicKey" AS "${actorTableName}.publicKey"`,
-      `"${actorTableName}"."privateKey" AS "${actorTableName}.privateKey"`,
-      `"${actorTableName}"."followersCount" AS "${actorTableName}.followersCount"`,
-      `"${actorTableName}"."followingCount" AS "${actorTableName}.followingCount"`,
-      `"${actorTableName}"."inboxUrl" AS "${actorTableName}.inboxUrl"`,
-      `"${actorTableName}"."outboxUrl" AS "${actorTableName}.outboxUrl"`,
-      `"${actorTableName}"."sharedInboxUrl" AS "${actorTableName}.sharedInboxUrl"`,
-      `"${actorTableName}"."followersUrl" AS "${actorTableName}.followersUrl"`,
-      `"${actorTableName}"."followingUrl" AS "${actorTableName}.followingUrl"`,
-      `"${actorTableName}"."remoteCreatedAt" AS "${actorTableName}.remoteCreatedAt"`,
-      `"${actorTableName}"."serverId" AS "${actorTableName}.serverId"`,
-      `"${actorTableName}"."createdAt" AS "${actorTableName}.createdAt"`,
-      `"${actorTableName}"."updatedAt" AS "${actorTableName}.updatedAt"`
-    ].join(', ')
+    return ActorModel.getSQLAttributes(actorTableName, `${actorTableName}.`).join(', ')
   }
 
+  @Memoize()
   getServerAttributes (actorTableName: string) {
-    return [
-      `"${actorTableName}->Server"."id" AS "${actorTableName}.Server.id"`,
-      `"${actorTableName}->Server"."host" AS "${actorTableName}.Server.host"`,
-      `"${actorTableName}->Server"."redundancyAllowed" AS "${actorTableName}.Server.redundancyAllowed"`,
-      `"${actorTableName}->Server"."createdAt" AS "${actorTableName}.Server.createdAt"`,
-      `"${actorTableName}->Server"."updatedAt" AS "${actorTableName}.Server.updatedAt"`
-    ].join(', ')
+    return ServerModel.getSQLAttributes(`${actorTableName}->Server`, `${actorTableName}.Server.`).join(', ')
   }
 
+  @Memoize()
   getAvatarAttributes (actorTableName: string) {
-    return [
-      `"${actorTableName}->Avatars"."id" AS "${actorTableName}.Avatars.id"`,
-      `"${actorTableName}->Avatars"."filename" AS "${actorTableName}.Avatars.filename"`,
-      `"${actorTableName}->Avatars"."height" AS "${actorTableName}.Avatars.height"`,
-      `"${actorTableName}->Avatars"."width" AS "${actorTableName}.Avatars.width"`,
-      `"${actorTableName}->Avatars"."fileUrl" AS "${actorTableName}.Avatars.fileUrl"`,
-      `"${actorTableName}->Avatars"."onDisk" AS "${actorTableName}.Avatars.onDisk"`,
-      `"${actorTableName}->Avatars"."type" AS "${actorTableName}.Avatars.type"`,
-      `"${actorTableName}->Avatars"."actorId" AS "${actorTableName}.Avatars.actorId"`,
-      `"${actorTableName}->Avatars"."createdAt" AS "${actorTableName}.Avatars.createdAt"`,
-      `"${actorTableName}->Avatars"."updatedAt" AS "${actorTableName}.Avatars.updatedAt"`
-    ].join(', ')
+    return ActorImageModel.getSQLAttributes(`${actorTableName}->Avatars`, `${actorTableName}.Avatars.`).join(', ')
   }
 }
index 1d70fbe702e8e5377b548d5aaadcfb9ddf2c21b7..d9593e48b8fa911c1ccbab43a14f377eae28549e 100644 (file)
@@ -1,7 +1,7 @@
 import { Sequelize } from 'sequelize'
 import { AbstractRunQuery } from '@server/models/shared'
-import { getInstanceFollowsSort } from '@server/models/utils'
 import { ActorImageType } from '@shared/models'
+import { getInstanceFollowsSort } from '../../../shared'
 import { ActorFollowTableAttributes } from './actor-follow-table-attributes'
 
 type BaseOptions = {
index 15909d5f32154fee2dc6b1bd038f8532d55d2cee..c2a72b71f42d554bae801ca1e106f88a741d2bd8 100644 (file)
@@ -34,7 +34,7 @@ import { CONFIG } from '../../initializers/config'
 import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
 import { ActorModel } from '../actor/actor'
 import { ServerModel } from '../server/server'
-import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
+import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../shared'
 import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
 import { VideoModel } from '../video/video'
 import { VideoChannelModel } from '../video/video-channel'
index 71c205ffaa2fd1b1904c9e7b118f29e3ef0e6aab..9948c9f7ac27d3366acd5f6dbac41767d1c5144f 100644 (file)
@@ -11,7 +11,7 @@ import {
   isPluginStableVersionValid,
   isPluginTypeValid
 } from '../../helpers/custom-validators/plugins'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 
 @DefaultScope(() => ({
   attributes: {
index 9752dfbc3f0a0c3330cd17364194decd765146b9..3d755fe4a1e40f71864945ac9f568dbc1150e293 100644 (file)
@@ -4,7 +4,7 @@ import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormat
 import { ServerBlock } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { AccountModel } from '../account/account'
-import { createSafeIn, getSort, searchAttribute } from '../utils'
+import { createSafeIn, getSort, searchAttribute } from '../shared'
 import { ServerModel } from './server'
 
 enum ScopeNames {
index ef42de09063b0f5ed7bf9d511e7d7d01c4fe65cf..a5e05f460fffcfe90c8026321f6df023c513b121 100644 (file)
@@ -4,7 +4,7 @@ import { MServer, MServerFormattable } from '@server/types/models/server'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { isHostValid } from '../../helpers/custom-validators/servers'
 import { ActorModel } from '../actor/actor'
-import { throwIfNotValid } from '../utils'
+import { buildSQLAttributes, throwIfNotValid } from '../shared'
 import { ServerBlocklistModel } from './server-blocklist'
 
 @Table({
@@ -52,6 +52,18 @@ export class ServerModel extends Model<Partial<AttributesOnly<ServerModel>>> {
   })
   BlockedBy: ServerBlocklistModel[]
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static load (id: number, transaction?: Transaction): Promise<MServer> {
     const query = {
       where: {
index 04528929c71351946da4bec8ede3500634bb2245..5a7621e4df4e43205ccd62b23d8e61f08701cf90 100644 (file)
@@ -1,4 +1,8 @@
 export * from './abstract-run-query'
 export * from './model-builder'
+export * from './model-cache'
 export * from './query'
+export * from './sequelize-helpers'
+export * from './sort'
+export * from './sql'
 export * from './update'
index c015ca4f5eeb9aa8bb66e3ad7ae757700d0224b8..07f7c40386b19e14c535b868c6ed4c6646f6b27e 100644 (file)
@@ -1,7 +1,24 @@
 import { isPlainObject } from 'lodash'
-import { Model as SequelizeModel, Sequelize } from 'sequelize'
+import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
 import { logger } from '@server/helpers/logger'
 
+/**
+ *
+ * Build Sequelize models from sequelize raw query (that must use { nest: true } options)
+ *
+ * In order to sequelize to correctly build the JSON this class will ingest,
+ * the columns selected in the raw query should be in the following form:
+ *   * All tables must be Pascal Cased (for example "VideoChannel")
+ *   * Root table must end with `Model` (for example "VideoCommentModel")
+ *   * Joined tables must contain the origin table name + '->JoinedTable'. For example:
+ *     * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
+ *     * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
+ *   * Selected columns must be renamed to contain the JSON path:
+ *     * "videoComment"."id": "VideoCommentModel"."id"
+ *     * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
+ *   * All tables must contain the row id
+ */
+
 export class ModelBuilder <T extends SequelizeModel> {
   private readonly modelRegistry = new Map<string, T>()
 
@@ -72,18 +89,18 @@ export class ModelBuilder <T extends SequelizeModel> {
         'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
         { existing: this.sequelize.modelManager.all.map(m => m.name) }
       )
-      return undefined
+      return { created: false, model: null }
     }
 
-    // FIXME: typings
-    const model = new (Model as any)(json)
+    const model = Model.build(json, { raw: true, isNewRecord: false })
+
     this.modelRegistry.set(registryKey, model)
 
     return { created: true, model }
   }
 
   private findModelBuilder (modelName: string) {
-    return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName))
+    return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
   }
 
   private buildSequelizeModelName (modelName: string) {
index 036cc13c6aec131168ef5551cc7b5d7fea3732b5..934acc21f7c364aca4e27fcfebb4498f9019d5d0 100644 (file)
@@ -1,17 +1,82 @@
-import { BindOrReplacements, QueryTypes } from 'sequelize'
-import { sequelizeTypescript } from '@server/initializers/database'
+import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize'
+import validator from 'validator'
+import { forceNumber } from '@shared/core-utils'
 
-function doesExist (query: string, bind?: BindOrReplacements) {
+function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) {
   const options = {
     type: QueryTypes.SELECT as QueryTypes.SELECT,
     bind,
     raw: true
   }
 
-  return sequelizeTypescript.query(query, options)
+  return sequelize.query(query, options)
             .then(results => results.length === 1)
 }
 
+function createSimilarityAttribute (col: string, value: string) {
+  return Sequelize.fn(
+    'similarity',
+
+    searchTrigramNormalizeCol(col),
+
+    searchTrigramNormalizeValue(value)
+  )
+}
+
+function buildWhereIdOrUUID (id: number | string) {
+  return validator.isInt('' + id) ? { id } : { uuid: id }
+}
+
+function parseAggregateResult (result: any) {
+  if (!result) return 0
+
+  const total = forceNumber(result)
+  if (isNaN(total)) return 0
+
+  return total
+}
+
+function parseRowCountResult (result: any) {
+  if (result.length !== 0) return result[0].total
+
+  return 0
+}
+
+function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
+  return toEscape.map(t => {
+    return t === null
+      ? null
+      : sequelize.escape('' + t)
+  }).concat(additionalUnescaped).join(', ')
+}
+
+function searchAttribute (sourceField?: string, targetField?: string) {
+  if (!sourceField) return {}
+
+  return {
+    [targetField]: {
+      // FIXME: ts error
+      [Op.iLike as any]: `%${sourceField}%`
+    }
+  }
+}
+
 export {
-  doesExist
+  doesExist,
+  createSimilarityAttribute,
+  buildWhereIdOrUUID,
+  parseAggregateResult,
+  parseRowCountResult,
+  createSafeIn,
+  searchAttribute
+}
+
+// ---------------------------------------------------------------------------
+
+function searchTrigramNormalizeValue (value: string) {
+  return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
+}
+
+function searchTrigramNormalizeCol (col: string) {
+  return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
 }
diff --git a/server/models/shared/sequelize-helpers.ts b/server/models/shared/sequelize-helpers.ts
new file mode 100644 (file)
index 0000000..7af8471
--- /dev/null
@@ -0,0 +1,39 @@
+import { Sequelize } from 'sequelize'
+
+function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
+  if (!model.createdAt || !model.updatedAt) {
+    throw new Error('Miss createdAt & updatedAt attributes to model')
+  }
+
+  const now = Date.now()
+  const createdAtTime = model.createdAt.getTime()
+  const updatedAtTime = model.updatedAt.getTime()
+
+  return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
+}
+
+function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
+  if (nullable && (value === null || value === undefined)) return
+
+  if (validator(value) === false) {
+    throw new Error(`"${value}" is not a valid ${fieldName}.`)
+  }
+}
+
+function buildTrigramSearchIndex (indexName: string, attribute: string) {
+  return {
+    name: indexName,
+    // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
+    fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
+    using: 'gin',
+    operator: 'gin_trgm_ops'
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  throwIfNotValid,
+  buildTrigramSearchIndex,
+  isOutdated
+}
diff --git a/server/models/shared/sort.ts b/server/models/shared/sort.ts
new file mode 100644 (file)
index 0000000..77e84dc
--- /dev/null
@@ -0,0 +1,160 @@
+import { literal, OrderItem, Sequelize } from 'sequelize'
+
+// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
+function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  let finalField: string | ReturnType<typeof Sequelize.col>
+
+  if (field.toLowerCase() === 'match') { // Search
+    finalField = Sequelize.col('similarity')
+  } else {
+    finalField = field
+  }
+
+  return [ [ finalField, direction ], lastSort ]
+}
+
+function getAdminUsersSort (value: string): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  let finalField: string | ReturnType<typeof Sequelize.col>
+
+  if (field === 'videoQuotaUsed') { // Users list
+    finalField = Sequelize.col('videoQuotaUsed')
+  } else {
+    finalField = field
+  }
+
+  const nullPolicy = direction === 'ASC'
+    ? 'NULLS FIRST'
+    : 'NULLS LAST'
+
+  // FIXME: typings
+  return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
+}
+
+function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  if (field.toLowerCase() === 'name') {
+    return [ [ 'displayName', direction ], lastSort ]
+  }
+
+  return getSort(value, lastSort)
+}
+
+function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  if (field === 'totalReplies') {
+    return [
+      [ Sequelize.literal('"totalReplies"'), direction ],
+      lastSort
+    ]
+  }
+
+  return getSort(value, lastSort)
+}
+
+function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  if (field.toLowerCase() === 'trending') { // Sort by aggregation
+    return [
+      [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
+
+      [ Sequelize.col('VideoModel.views'), direction ],
+
+      lastSort
+    ]
+  } else if (field === 'publishedAt') {
+    return [
+      [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
+
+      [ Sequelize.col('VideoModel.publishedAt'), direction ],
+
+      lastSort
+    ]
+  }
+
+  let finalField: string | ReturnType<typeof Sequelize.col>
+
+  // Alias
+  if (field.toLowerCase() === 'match') { // Search
+    finalField = Sequelize.col('similarity')
+  } else {
+    finalField = field
+  }
+
+  const firstSort: OrderItem = typeof finalField === 'string'
+    ? finalField.split('.').concat([ direction ]) as OrderItem
+    : [ finalField, direction ]
+
+  return [ firstSort, lastSort ]
+}
+
+function getBlacklistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  const videoFields = new Set([ 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid' ])
+
+  if (videoFields.has(field)) {
+    return [
+      [ literal(`"Video.${field}" ${direction}`) ],
+      lastSort
+    ] as OrderItem[]
+  }
+
+  return getSort(value, lastSort)
+}
+
+function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+
+  if (field === 'redundancyAllowed') {
+    return [
+      [ 'ActorFollowing.Server.redundancyAllowed', direction ],
+      lastSort
+    ]
+  }
+
+  return getSort(value, lastSort)
+}
+
+function getChannelSyncSort (value: string): OrderItem[] {
+  const { direction, field } = buildSortDirectionAndField(value)
+  if (field.toLowerCase() === 'videochannel') {
+    return [
+      [ literal('"VideoChannel.name"'), direction ]
+    ]
+  }
+  return [ [ field, direction ] ]
+}
+
+function buildSortDirectionAndField (value: string) {
+  let field: string
+  let direction: 'ASC' | 'DESC'
+
+  if (value.substring(0, 1) === '-') {
+    direction = 'DESC'
+    field = value.substring(1)
+  } else {
+    direction = 'ASC'
+    field = value
+  }
+
+  return { direction, field }
+}
+
+export {
+  buildSortDirectionAndField,
+  getPlaylistSort,
+  getSort,
+  getCommentSort,
+  getAdminUsersSort,
+  getVideoSort,
+  getBlacklistSort,
+  getChannelSyncSort,
+  getInstanceFollowsSort
+}
diff --git a/server/models/shared/sql.ts b/server/models/shared/sql.ts
new file mode 100644 (file)
index 0000000..5aaeb49
--- /dev/null
@@ -0,0 +1,68 @@
+import { literal, Model, ModelStatic } from 'sequelize'
+import { forceNumber } from '@shared/core-utils'
+import { AttributesOnly } from '@shared/typescript-utils'
+
+function buildLocalAccountIdsIn () {
+  return literal(
+    '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
+  )
+}
+
+function buildLocalActorIdsIn () {
+  return literal(
+    '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
+  )
+}
+
+function buildBlockedAccountSQL (blockerIds: number[]) {
+  const blockerIdsString = blockerIds.join(', ')
+
+  return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
+    ' UNION ' +
+    'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
+    'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
+    'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
+}
+
+function buildServerIdsFollowedBy (actorId: any) {
+  const actorIdNumber = forceNumber(actorId)
+
+  return '(' +
+    'SELECT "actor"."serverId" FROM "actorFollow" ' +
+    'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
+    'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
+    ')'
+}
+
+function buildSQLAttributes<M extends Model> (options: {
+  model: ModelStatic<M>
+  tableName: string
+
+  excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[]
+  aliasPrefix?: string
+}) {
+  const { model, tableName, aliasPrefix, excludeAttributes } = options
+
+  const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[]
+
+  return attributes
+    .filter(a => {
+      if (!excludeAttributes) return true
+      if (excludeAttributes.includes(a)) return false
+
+      return true
+    })
+    .map(a => {
+      return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"`
+    })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  buildSQLAttributes,
+  buildBlockedAccountSQL,
+  buildServerIdsFollowedBy,
+  buildLocalAccountIdsIn,
+  buildLocalActorIdsIn
+}
index d338211e380bbc5cf27cf15148df1d09ec932016..d02c4535dc5ec02783d6bdd2891c3f01df23a30c 100644 (file)
@@ -1,9 +1,15 @@
-import { QueryTypes, Transaction } from 'sequelize'
-import { sequelizeTypescript } from '@server/initializers/database'
+import { QueryTypes, Sequelize, Transaction } from 'sequelize'
 
 // Sequelize always skip the update if we only update updatedAt field
-function setAsUpdated (table: string, id: number, transaction?: Transaction) {
-  return sequelizeTypescript.query(
+function setAsUpdated (options: {
+  sequelize: Sequelize
+  table: string
+  id: number
+  transaction?: Transaction
+}) {
+  const { sequelize, table, id, transaction } = options
+
+  return sequelize.query(
     `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`,
     {
       replacements: { table, id, updatedAt: new Date() },
index 31b4932bf47466c5d8159b8582d2defbe3978c90..d11546df07a91e6becae73f86f6963966a6ecec4 100644 (file)
@@ -1,8 +1,8 @@
 import { Sequelize } from 'sequelize'
 import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
-import { getSort } from '@server/models/utils'
 import { UserNotificationModelForApi } from '@server/types/models'
 import { ActorImageType } from '@shared/models'
+import { getSort } from '../../shared'
 
 export interface ListNotificationsOptions {
   userId: number
index 66e1d85b31fe10476e0d511f2b677144b4bc8fa9..394494c0ce6c0f0a99950f1a12c32dcaa6e6ef0c 100644 (file)
@@ -17,7 +17,7 @@ import { MNotificationSettingFormattable } from '@server/types/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
 import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid } from '../shared'
 import { UserModel } from './user'
 
 @Table({
index d37fa5dc7129fc7f37e959e247451e1ecf1a3302..6e134158fc7eb0f2a210f7d8e07fce04095ca7cc 100644 (file)
@@ -13,7 +13,7 @@ import { AccountModel } from '../account/account'
 import { ActorFollowModel } from '../actor/actor-follow'
 import { ApplicationModel } from '../application/application'
 import { PluginModel } from '../server/plugin'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { VideoBlacklistModel } from '../video/video-blacklist'
 import { VideoCommentModel } from '../video/video-comment'
index 672728a2a98ab81bdcf9ebfc7e23753b52bec97a..0932a367a3c0ea76f998284c136fcb8341c6de1c 100644 (file)
@@ -30,6 +30,7 @@ import {
   MUserNotifSettingChannelDefault,
   MUserWithNotificationSetting
 } from '@server/types/models'
+import { forceNumber } from '@shared/core-utils'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
 import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models'
@@ -63,14 +64,13 @@ import { ActorModel } from '../actor/actor'
 import { ActorFollowModel } from '../actor/actor-follow'
 import { ActorImageModel } from '../actor/actor-image'
 import { OAuthTokenModel } from '../oauth/oauth-token'
-import { getAdminUsersSort, throwIfNotValid } from '../utils'
+import { getAdminUsersSort, throwIfNotValid } from '../shared'
 import { VideoModel } from '../video/video'
 import { VideoChannelModel } from '../video/video-channel'
 import { VideoImportModel } from '../video/video-import'
 import { VideoLiveModel } from '../video/video-live'
 import { VideoPlaylistModel } from '../video/video-playlist'
 import { UserNotificationSettingModel } from './user-notification-setting'
-import { forceNumber } from '@shared/core-utils'
 
 enum ScopeNames {
   FOR_ME_API = 'FOR_ME_API',
diff --git a/server/models/utils.ts b/server/models/utils.ts
deleted file mode 100644 (file)
index 3476799..0000000
+++ /dev/null
@@ -1,317 +0,0 @@
-import { literal, Op, OrderItem, Sequelize } from 'sequelize'
-import validator from 'validator'
-import { forceNumber } from '@shared/core-utils'
-
-type SortType = { sortModel: string, sortValue: string }
-
-// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
-function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  let finalField: string | ReturnType<typeof Sequelize.col>
-
-  if (field.toLowerCase() === 'match') { // Search
-    finalField = Sequelize.col('similarity')
-  } else {
-    finalField = field
-  }
-
-  return [ [ finalField, direction ], lastSort ]
-}
-
-function getAdminUsersSort (value: string): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  let finalField: string | ReturnType<typeof Sequelize.col>
-
-  if (field === 'videoQuotaUsed') { // Users list
-    finalField = Sequelize.col('videoQuotaUsed')
-  } else {
-    finalField = field
-  }
-
-  const nullPolicy = direction === 'ASC'
-    ? 'NULLS FIRST'
-    : 'NULLS LAST'
-
-  // FIXME: typings
-  return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
-}
-
-function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  if (field.toLowerCase() === 'name') {
-    return [ [ 'displayName', direction ], lastSort ]
-  }
-
-  return getSort(value, lastSort)
-}
-
-function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  if (field === 'totalReplies') {
-    return [
-      [ Sequelize.literal('"totalReplies"'), direction ],
-      lastSort
-    ]
-  }
-
-  return getSort(value, lastSort)
-}
-
-function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  if (field.toLowerCase() === 'trending') { // Sort by aggregation
-    return [
-      [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
-
-      [ Sequelize.col('VideoModel.views'), direction ],
-
-      lastSort
-    ]
-  } else if (field === 'publishedAt') {
-    return [
-      [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
-
-      [ Sequelize.col('VideoModel.publishedAt'), direction ],
-
-      lastSort
-    ]
-  }
-
-  let finalField: string | ReturnType<typeof Sequelize.col>
-
-  // Alias
-  if (field.toLowerCase() === 'match') { // Search
-    finalField = Sequelize.col('similarity')
-  } else {
-    finalField = field
-  }
-
-  const firstSort: OrderItem = typeof finalField === 'string'
-    ? finalField.split('.').concat([ direction ]) as OrderItem
-    : [ finalField, direction ]
-
-  return [ firstSort, lastSort ]
-}
-
-function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const [ firstSort ] = getSort(value)
-
-  if (model) return [ [ literal(`"${model}.${firstSort[0]}" ${firstSort[1]}`) ], lastSort ] as OrderItem[]
-  return [ firstSort, lastSort ]
-}
-
-function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-
-  if (field === 'redundancyAllowed') {
-    return [
-      [ 'ActorFollowing.Server.redundancyAllowed', direction ],
-      lastSort
-    ]
-  }
-
-  return getSort(value, lastSort)
-}
-
-function getChannelSyncSort (value: string): OrderItem[] {
-  const { direction, field } = buildDirectionAndField(value)
-  if (field.toLowerCase() === 'videochannel') {
-    return [
-      [ literal('"VideoChannel.name"'), direction ]
-    ]
-  }
-  return [ [ field, direction ] ]
-}
-
-function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
-  if (!model.createdAt || !model.updatedAt) {
-    throw new Error('Miss createdAt & updatedAt attributes to model')
-  }
-
-  const now = Date.now()
-  const createdAtTime = model.createdAt.getTime()
-  const updatedAtTime = model.updatedAt.getTime()
-
-  return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
-}
-
-function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
-  if (nullable && (value === null || value === undefined)) return
-
-  if (validator(value) === false) {
-    throw new Error(`"${value}" is not a valid ${fieldName}.`)
-  }
-}
-
-function buildTrigramSearchIndex (indexName: string, attribute: string) {
-  return {
-    name: indexName,
-    // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
-    fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
-    using: 'gin',
-    operator: 'gin_trgm_ops'
-  }
-}
-
-function createSimilarityAttribute (col: string, value: string) {
-  return Sequelize.fn(
-    'similarity',
-
-    searchTrigramNormalizeCol(col),
-
-    searchTrigramNormalizeValue(value)
-  )
-}
-
-function buildBlockedAccountSQL (blockerIds: number[]) {
-  const blockerIdsString = blockerIds.join(', ')
-
-  return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
-    ' UNION ' +
-    'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
-    'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
-    'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
-}
-
-function buildBlockedAccountSQLOptimized (columnNameJoin: string, blockerIds: number[]) {
-  const blockerIdsString = blockerIds.join(', ')
-
-  return [
-    literal(
-      `NOT EXISTS (` +
-      `  SELECT 1 FROM "accountBlocklist" ` +
-      `  WHERE "targetAccountId" = ${columnNameJoin} ` +
-      `  AND "accountId" IN (${blockerIdsString})` +
-      `)`
-    ),
-
-    literal(
-      `NOT EXISTS (` +
-      `  SELECT 1 FROM "account" ` +
-      `  INNER JOIN "actor" ON account."actorId" = actor.id ` +
-      `  INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
-      `  WHERE "account"."id" = ${columnNameJoin} ` +
-      `  AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
-      `)`
-    )
-  ]
-}
-
-function buildServerIdsFollowedBy (actorId: any) {
-  const actorIdNumber = forceNumber(actorId)
-
-  return '(' +
-    'SELECT "actor"."serverId" FROM "actorFollow" ' +
-    'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
-    'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
-  ')'
-}
-
-function buildWhereIdOrUUID (id: number | string) {
-  return validator.isInt('' + id) ? { id } : { uuid: id }
-}
-
-function parseAggregateResult (result: any) {
-  if (!result) return 0
-
-  const total = forceNumber(result)
-  if (isNaN(total)) return 0
-
-  return total
-}
-
-function parseRowCountResult (result: any) {
-  if (result.length !== 0) return result[0].total
-
-  return 0
-}
-
-function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
-  return stringArr.map(t => {
-    return t === null
-      ? null
-      : sequelize.escape('' + t)
-  }).join(', ')
-}
-
-function buildLocalAccountIdsIn () {
-  return literal(
-    '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
-  )
-}
-
-function buildLocalActorIdsIn () {
-  return literal(
-    '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
-  )
-}
-
-function buildDirectionAndField (value: string) {
-  let field: string
-  let direction: 'ASC' | 'DESC'
-
-  if (value.substring(0, 1) === '-') {
-    direction = 'DESC'
-    field = value.substring(1)
-  } else {
-    direction = 'ASC'
-    field = value
-  }
-
-  return { direction, field }
-}
-
-function searchAttribute (sourceField?: string, targetField?: string) {
-  if (!sourceField) return {}
-
-  return {
-    [targetField]: {
-      // FIXME: ts error
-      [Op.iLike as any]: `%${sourceField}%`
-    }
-  }
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  buildBlockedAccountSQL,
-  buildBlockedAccountSQLOptimized,
-  buildLocalActorIdsIn,
-  getPlaylistSort,
-  SortType,
-  buildLocalAccountIdsIn,
-  getSort,
-  getCommentSort,
-  getAdminUsersSort,
-  getVideoSort,
-  getBlacklistSort,
-  getChannelSyncSort,
-  createSimilarityAttribute,
-  throwIfNotValid,
-  buildServerIdsFollowedBy,
-  buildTrigramSearchIndex,
-  buildWhereIdOrUUID,
-  isOutdated,
-  parseAggregateResult,
-  getInstanceFollowsSort,
-  buildDirectionAndField,
-  createSafeIn,
-  searchAttribute,
-  parseRowCountResult
-}
-
-// ---------------------------------------------------------------------------
-
-function searchTrigramNormalizeValue (value: string) {
-  return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
-}
-
-function searchTrigramNormalizeCol (col: string) {
-  return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
-}
diff --git a/server/models/video/sql/comment/video-comment-list-query-builder.ts b/server/models/video/sql/comment/video-comment-list-query-builder.ts
new file mode 100644 (file)
index 0000000..3960f6b
--- /dev/null
@@ -0,0 +1,385 @@
+import { Model, Sequelize, Transaction } from 'sequelize'
+import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
+import { ActorImageType, VideoPrivacy } from '@shared/models'
+import { createSafeIn, getCommentSort, parseRowCountResult } from '../../../shared'
+import { VideoCommentTableAttributes } from './video-comment-table-attributes'
+
+export interface ListVideoCommentsOptions {
+  selectType: 'api' | 'feed' | 'comment-only'
+
+  start?: number
+  count?: number
+  sort?: string
+
+  videoId?: number
+  threadId?: number
+  accountId?: number
+  videoChannelId?: number
+
+  blockerAccountIds?: number[]
+
+  isThread?: boolean
+  notDeleted?: boolean
+  isLocal?: boolean
+  onLocalVideo?: boolean
+  onPublicVideo?: boolean
+  videoAccountOwnerId?: boolean
+
+  search?: string
+  searchAccount?: string
+  searchVideo?: string
+
+  includeReplyCounters?: boolean
+
+  transaction?: Transaction
+}
+
+export class VideoCommentListQueryBuilder extends AbstractRunQuery {
+  private readonly tableAttributes = new VideoCommentTableAttributes()
+
+  private innerQuery: string
+
+  private select = ''
+  private joins = ''
+
+  private innerSelect = ''
+  private innerJoins = ''
+  private innerWhere = ''
+
+  private readonly built = {
+    cte: false,
+    accountJoin: false,
+    videoJoin: false,
+    videoChannelJoin: false,
+    avatarJoin: false
+  }
+
+  constructor (
+    protected readonly sequelize: Sequelize,
+    private readonly options: ListVideoCommentsOptions
+  ) {
+    super(sequelize)
+  }
+
+  async listComments <T extends Model> () {
+    this.buildListQuery()
+
+    const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
+    const modelBuilder = new ModelBuilder<T>(this.sequelize)
+
+    return modelBuilder.createModels(results, 'VideoComment')
+  }
+
+  async countComments () {
+    this.buildCountQuery()
+
+    const result = await this.runQuery({ transaction: this.options.transaction })
+
+    return parseRowCountResult(result)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private buildListQuery () {
+    this.buildInnerListQuery()
+    this.buildListSelect()
+
+    this.query = `${this.select} ` +
+      `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
+      `${this.joins} ` +
+      `${this.getOrder()}`
+  }
+
+  private buildInnerListQuery () {
+    this.buildWhere()
+    this.buildInnerListSelect()
+
+    this.innerQuery = `${this.innerSelect} ` +
+      `FROM "videoComment" AS "VideoCommentModel" ` +
+      `${this.innerJoins} ` +
+      `${this.innerWhere} ` +
+      `${this.getOrder()} ` +
+      `${this.getInnerLimit()}`
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private buildCountQuery () {
+    this.buildWhere()
+
+    this.query = `SELECT COUNT(*) AS "total" ` +
+      `FROM "videoComment" AS "VideoCommentModel" ` +
+      `${this.innerJoins} ` +
+      `${this.innerWhere}`
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private buildWhere () {
+    let where: string[] = []
+
+    if (this.options.videoId) {
+      this.replacements.videoId = this.options.videoId
+
+      where.push('"VideoCommentModel"."videoId" = :videoId')
+    }
+
+    if (this.options.threadId) {
+      this.replacements.threadId = this.options.threadId
+
+      where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
+    }
+
+    if (this.options.accountId) {
+      this.replacements.accountId = this.options.accountId
+
+      where.push('"VideoCommentModel"."accountId" = :accountId')
+    }
+
+    if (this.options.videoChannelId) {
+      this.buildVideoChannelJoin()
+
+      this.replacements.videoChannelId = this.options.videoChannelId
+
+      where.push('"Account->VideoChannel"."id" = :videoChannelId')
+    }
+
+    if (this.options.blockerAccountIds) {
+      this.buildVideoChannelJoin()
+
+      where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
+    }
+
+    if (this.options.isThread === true) {
+      where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
+    }
+
+    if (this.options.notDeleted === true) {
+      where.push('"VideoCommentModel"."deletedAt" IS NULL')
+    }
+
+    if (this.options.isLocal === true) {
+      this.buildAccountJoin()
+
+      where.push('"Account->Actor"."serverId" IS NULL')
+    } else if (this.options.isLocal === false) {
+      this.buildAccountJoin()
+
+      where.push('"Account->Actor"."serverId" IS NOT NULL')
+    }
+
+    if (this.options.onLocalVideo === true) {
+      this.buildVideoJoin()
+
+      where.push('"Video"."remote" IS FALSE')
+    } else if (this.options.onLocalVideo === false) {
+      this.buildVideoJoin()
+
+      where.push('"Video"."remote" IS TRUE')
+    }
+
+    if (this.options.onPublicVideo === true) {
+      this.buildVideoJoin()
+
+      where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
+    }
+
+    if (this.options.videoAccountOwnerId) {
+      this.buildVideoChannelJoin()
+
+      this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
+
+      where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
+    }
+
+    if (this.options.search) {
+      this.buildVideoJoin()
+      this.buildAccountJoin()
+
+      const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
+
+      where.push(
+        `(` +
+          `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
+          `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
+          `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
+          `"Video"."name" ILIKE ${escapedLikeSearch} ` +
+        `)`
+      )
+    }
+
+    if (this.options.searchAccount) {
+      this.buildAccountJoin()
+
+      const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
+
+      where.push(
+        `(` +
+          `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
+          `"Account"."name" ILIKE ${escapedLikeSearch} ` +
+        `)`
+      )
+    }
+
+    if (this.options.searchVideo) {
+      this.buildVideoJoin()
+
+      const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
+
+      where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
+    }
+
+    if (where.length !== 0) {
+      this.innerWhere = `WHERE ${where.join(' AND ')}`
+    }
+  }
+
+  private buildAccountJoin () {
+    if (this.built.accountJoin) return
+
+    this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
+      'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
+      'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
+
+    this.built.accountJoin = true
+  }
+
+  private buildVideoJoin () {
+    if (this.built.videoJoin) return
+
+    this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
+
+    this.built.videoJoin = true
+  }
+
+  private buildVideoChannelJoin () {
+    if (this.built.videoChannelJoin) return
+
+    this.buildVideoJoin()
+
+    this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
+
+    this.built.videoChannelJoin = true
+  }
+
+  private buildAvatarsJoin () {
+    if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
+    if (this.built.avatarJoin) return
+
+    this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
+      `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
+        `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
+
+    this.built.avatarJoin = true
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private buildListSelect () {
+    const toSelect = [ '"VideoCommentModel".*' ]
+
+    if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
+      this.buildAvatarsJoin()
+
+      toSelect.push(this.tableAttributes.getAvatarAttributes())
+    }
+
+    if (this.options.includeReplyCounters === true) {
+      toSelect.push(this.getTotalRepliesSelect())
+      toSelect.push(this.getAuthorTotalRepliesSelect())
+    }
+
+    this.select = this.buildSelect(toSelect)
+  }
+
+  private buildInnerListSelect () {
+    let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
+
+    if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
+      this.buildAccountJoin()
+      this.buildVideoJoin()
+
+      toSelect = toSelect.concat([
+        this.tableAttributes.getVideoAttributes(),
+        this.tableAttributes.getAccountAttributes(),
+        this.tableAttributes.getActorAttributes(),
+        this.tableAttributes.getServerAttributes()
+      ])
+    }
+
+    this.innerSelect = this.buildSelect(toSelect)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private getBlockWhere (commentTableName: string, channelTableName: string) {
+    const where: string[] = []
+
+    const blockerIdsString = createSafeIn(
+      this.sequelize,
+      this.options.blockerAccountIds,
+      [ `"${channelTableName}"."accountId"` ]
+    )
+
+    where.push(
+      `NOT EXISTS (` +
+        `SELECT 1 FROM "accountBlocklist" ` +
+        `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
+        `AND "accountId" IN (${blockerIdsString})` +
+      `)`
+    )
+
+    where.push(
+      `NOT EXISTS (` +
+        `SELECT 1 FROM "account" ` +
+        `INNER JOIN "actor" ON account."actorId" = actor.id ` +
+        `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
+        `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
+        `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
+      `)`
+    )
+
+    return where
+  }
+
+  // ---------------------------------------------------------------------------
+
+  private getTotalRepliesSelect () {
+    const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
+
+    return `(` +
+      `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
+      `LEFT JOIN "video" ON "video"."id" = "replies"."videoId" ` +
+      `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
+      `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
+        `AND "deletedAt" IS NULL ` +
+        `AND ${blockWhereString} ` +
+    `) AS "totalReplies"`
+  }
+
+  private getAuthorTotalRepliesSelect () {
+    return `(` +
+      `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
+      `INNER JOIN "video" ON "video"."id" = "replies"."videoId" ` +
+      `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
+      `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
+    `) AS "totalRepliesFromVideoAuthor"`
+  }
+
+  private getOrder () {
+    if (!this.options.sort) return ''
+
+    const orders = getCommentSort(this.options.sort)
+
+    return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
+  }
+
+  private getInnerLimit () {
+    if (!this.options.count) return ''
+
+    this.replacements.limit = this.options.count
+    this.replacements.offset = this.options.start || 0
+
+    return `LIMIT :limit OFFSET :offset `
+  }
+}
diff --git a/server/models/video/sql/comment/video-comment-table-attributes.ts b/server/models/video/sql/comment/video-comment-table-attributes.ts
new file mode 100644 (file)
index 0000000..87f8750
--- /dev/null
@@ -0,0 +1,43 @@
+import { Memoize } from '@server/helpers/memoize'
+import { AccountModel } from '@server/models/account/account'
+import { ActorModel } from '@server/models/actor/actor'
+import { ActorImageModel } from '@server/models/actor/actor-image'
+import { ServerModel } from '@server/models/server/server'
+import { VideoCommentModel } from '../../video-comment'
+
+export class VideoCommentTableAttributes {
+
+  @Memoize()
+  getVideoCommentAttributes () {
+    return VideoCommentModel.getSQLAttributes('VideoCommentModel').join(', ')
+  }
+
+  @Memoize()
+  getAccountAttributes () {
+    return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
+  }
+
+  @Memoize()
+  getVideoAttributes () {
+    return [
+      `"Video"."id" AS "Video.id"`,
+      `"Video"."uuid" AS "Video.uuid"`,
+      `"Video"."name" AS "Video.name"`
+    ].join(', ')
+  }
+
+  @Memoize()
+  getActorAttributes () {
+    return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
+  }
+
+  @Memoize()
+  getServerAttributes () {
+    return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
+  }
+
+  @Memoize()
+  getAvatarAttributes () {
+    return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
+  }
+}
index f0ce69501aab1a2fd911658006cc305d6d73b0d8..cbd57ad8c922e009ff3fee4b8f0c1bae14baa459 100644 (file)
@@ -1,9 +1,9 @@
 import { Sequelize } from 'sequelize'
 import validator from 'validator'
-import { createSafeIn } from '@server/models/utils'
 import { MUserAccountId } from '@server/types/models'
 import { ActorImageType } from '@shared/models'
 import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
+import { createSafeIn } from '../../../../shared'
 import { VideoTableAttributes } from './video-table-attributes'
 
 /**
index 7c864bf27978b0e9cf92782edb4bfdf2d1945201..62f1855c74d055aaec81b2d607b12551406473c3 100644 (file)
@@ -2,11 +2,12 @@ import { Sequelize, Transaction } from 'sequelize'
 import validator from 'validator'
 import { exists } from '@server/helpers/custom-validators/misc'
 import { WEBSERVER } from '@server/initializers/constants'
-import { buildDirectionAndField, createSafeIn, parseRowCountResult } from '@server/models/utils'
+import { buildSortDirectionAndField } from '@server/models/shared'
 import { MUserAccountId, MUserId } from '@server/types/models'
+import { forceNumber } from '@shared/core-utils'
 import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
+import { createSafeIn, parseRowCountResult } from '../../../shared'
 import { AbstractRunQuery } from '../../../shared/abstract-run-query'
-import { forceNumber } from '@shared/core-utils'
 
 /**
  *
@@ -665,7 +666,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
   }
 
   private buildOrder (value: string) {
-    const { direction, field } = buildDirectionAndField(value)
+    const { direction, field } = buildSortDirectionAndField(value)
     if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
 
     if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
index 653b9694b8a6adef8a3c68cfb8b1822e6c2667c3..cebde3755e27669e5d587be7061569ad07907c2b 100644 (file)
@@ -4,7 +4,7 @@ import { MTag } from '@server/types/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
 import { isVideoTagValid } from '../../helpers/custom-validators/videos'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 import { VideoTagModel } from './video-tag'
 
index 1cd8224c0a97a8b0dbf63b6a105f4cec2014c219..9247d0e2b1fd5fff6964382b44879768d2eb4755 100644 (file)
@@ -5,7 +5,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
 import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
 import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
 import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
-import { getBlacklistSort, searchAttribute, SortType, throwIfNotValid } from '../utils'
+import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared'
 import { ThumbnailModel } from './thumbnail'
 import { VideoModel } from './video'
 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
@@ -57,7 +57,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
   static listForApi (parameters: {
     start: number
     count: number
-    sort: SortType
+    sort: string
     search?: string
     type?: VideoBlacklistType
   }) {
@@ -67,7 +67,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
       return {
         offset: start,
         limit: count,
-        order: getBlacklistSort(sort.sortModel, sort.sortValue)
+        order: getBlacklistSort(sort)
       }
     }
 
index 5fbcd6e3b9f278512ff3091b1e97dad8d01c1c8e..2eaa77407e35740f99bc72c92402a90d7089f55e 100644 (file)
@@ -23,7 +23,7 @@ import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/vid
 import { logger } from '../../helpers/logger'
 import { CONFIG } from '../../initializers/config'
 import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
-import { buildWhereIdOrUUID, throwIfNotValid } from '../utils'
+import { buildWhereIdOrUUID, throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 
 export enum ScopeNames {
index 1a1b8c88de203e55dbff3e1f11f02ab7d351a069..2db4b523a654b6fda12274fed3f54dfe2325a7ff 100644 (file)
@@ -3,7 +3,7 @@ import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@se
 import { AttributesOnly } from '@shared/typescript-utils'
 import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
 import { AccountModel } from '../account/account'
-import { getSort } from '../utils'
+import { getSort } from '../shared'
 import { ScopeNames as VideoScopeNames, VideoModel } from './video'
 
 enum ScopeNames {
index 6e49cde107750029938bcdf190faf166e70f5bc4..a4cbf51f527f2412eae587615d191c52c598b115 100644 (file)
@@ -21,7 +21,7 @@ import { VideoChannelSync, VideoChannelSyncState } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { AccountModel } from '../account/account'
 import { UserModel } from '../user/user'
-import { getChannelSyncSort, throwIfNotValid } from '../utils'
+import { getChannelSyncSort, throwIfNotValid } from '../shared'
 import { VideoChannelModel } from './video-channel'
 
 @DefaultScope(() => ({
index 132c8f0211ea78d83dc04199e68eabb2f2f3b2d4..b71f5a1971602a100119d63ecc42533e6e06804b 100644 (file)
@@ -43,8 +43,14 @@ import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
 import { ActorFollowModel } from '../actor/actor-follow'
 import { ActorImageModel } from '../actor/actor-image'
 import { ServerModel } from '../server/server'
-import { setAsUpdated } from '../shared'
-import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
+import {
+  buildServerIdsFollowedBy,
+  buildTrigramSearchIndex,
+  createSimilarityAttribute,
+  getSort,
+  setAsUpdated,
+  throwIfNotValid
+} from '../shared'
 import { VideoModel } from './video'
 import { VideoPlaylistModel } from './video-playlist'
 
@@ -831,6 +837,6 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
   }
 
   setAsUpdated (transaction?: Transaction) {
-    return setAsUpdated('videoChannel', this.id, transaction)
+    return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
   }
 }
index af9614d30439eb5dcd63a9d86b337bdfa62def77..ff514280936bb50cc39291293e2358f1bfe2c68a 100644 (file)
@@ -1,4 +1,4 @@
-import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
+import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
 import {
   AllowNull,
   BelongsTo,
@@ -13,11 +13,9 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { exists } from '@server/helpers/custom-validators/misc'
 import { getServerActor } from '@server/models/application/application'
 import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
-import { uniqify } from '@shared/core-utils'
-import { VideoPrivacy } from '@shared/models'
+import { pick, uniqify } from '@shared/core-utils'
 import { AttributesOnly } from '@shared/typescript-utils'
 import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
@@ -41,61 +39,19 @@ import {
 } from '../../types/models/video'
 import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
 import { AccountModel } from '../account/account'
-import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
-import {
-  buildBlockedAccountSQL,
-  buildBlockedAccountSQLOptimized,
-  buildLocalAccountIdsIn,
-  getCommentSort,
-  searchAttribute,
-  throwIfNotValid
-} from '../utils'
+import { ActorModel } from '../actor/actor'
+import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared'
+import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
 import { VideoModel } from './video'
 import { VideoChannelModel } from './video-channel'
 
 export enum ScopeNames {
   WITH_ACCOUNT = 'WITH_ACCOUNT',
-  WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
   WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
-  WITH_VIDEO = 'WITH_VIDEO',
-  ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
+  WITH_VIDEO = 'WITH_VIDEO'
 }
 
 @Scopes(() => ({
-  [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
-    return {
-      attributes: {
-        include: [
-          [
-            Sequelize.literal(
-              '(' +
-                'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
-                'SELECT COUNT("replies"."id") ' +
-                'FROM "videoComment" AS "replies" ' +
-                'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
-                'AND "deletedAt" IS NULL ' +
-                'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
-              ')'
-            ),
-            'totalReplies'
-          ],
-          [
-            Sequelize.literal(
-              '(' +
-                'SELECT COUNT("replies"."id") ' +
-                'FROM "videoComment" AS "replies" ' +
-                'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
-                'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
-                'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
-                'AND "replies"."accountId" = "videoChannel"."accountId"' +
-              ')'
-            ),
-            'totalRepliesFromVideoAuthor'
-          ]
-        ]
-      }
-    } as FindOptions
-  },
   [ScopeNames.WITH_ACCOUNT]: {
     include: [
       {
@@ -103,22 +59,6 @@ export enum ScopeNames {
       }
     ]
   },
-  [ScopeNames.WITH_ACCOUNT_FOR_API]: {
-    include: [
-      {
-        model: AccountModel.unscoped(),
-        include: [
-          {
-            attributes: {
-              exclude: unusedActorAttributesForAPI
-            },
-            model: ActorModel, // Default scope includes avatar and server
-            required: true
-          }
-        ]
-      }
-    ]
-  },
   [ScopeNames.WITH_IN_REPLY_TO]: {
     include: [
       {
@@ -252,6 +192,18 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
   })
   CommentAbuses: VideoCommentAbuseModel[]
 
+  // ---------------------------------------------------------------------------
+
+  static getSQLAttributes (tableName: string, aliasPrefix = '') {
+    return buildSQLAttributes({
+      model: this,
+      tableName,
+      aliasPrefix
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
   static loadById (id: number, t?: Transaction): Promise<MComment> {
     const query: FindOptions = {
       where: {
@@ -319,93 +271,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
     searchAccount?: string
     searchVideo?: string
   }) {
-    const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters
+    const queryOptions: ListVideoCommentsOptions = {
+      ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
 
-    const where: WhereOptions = {
-      deletedAt: null
-    }
-
-    const whereAccount: WhereOptions = {}
-    const whereActor: WhereOptions = {}
-    const whereVideo: WhereOptions = {}
-
-    if (isLocal === true) {
-      Object.assign(whereActor, {
-        serverId: null
-      })
-    } else if (isLocal === false) {
-      Object.assign(whereActor, {
-        serverId: {
-          [Op.ne]: null
-        }
-      })
-    }
-
-    if (search) {
-      Object.assign(where, {
-        [Op.or]: [
-          searchAttribute(search, 'text'),
-          searchAttribute(search, '$Account.Actor.preferredUsername$'),
-          searchAttribute(search, '$Account.name$'),
-          searchAttribute(search, '$Video.name$')
-        ]
-      })
-    }
-
-    if (searchAccount) {
-      Object.assign(whereActor, {
-        [Op.or]: [
-          searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
-          searchAttribute(searchAccount, '$Account.name$')
-        ]
-      })
-    }
-
-    if (searchVideo) {
-      Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
-    }
-
-    if (exists(onLocalVideo)) {
-      Object.assign(whereVideo, { remote: !onLocalVideo })
-    }
-
-    const getQuery = (forCount: boolean) => {
-      return {
-        offset: start,
-        limit: count,
-        order: getCommentSort(sort),
-        where,
-        include: [
-          {
-            model: AccountModel.unscoped(),
-            required: true,
-            where: whereAccount,
-            include: [
-              {
-                attributes: {
-                  exclude: unusedActorAttributesForAPI
-                },
-                model: forCount === true
-                  ? ActorModel.unscoped() // Default scope includes avatar and server
-                  : ActorModel,
-                required: true,
-                where: whereActor
-              }
-            ]
-          },
-          {
-            model: VideoModel.unscoped(),
-            required: true,
-            where: whereVideo
-          }
-        ]
-      }
+      selectType: 'api',
+      notDeleted: true
     }
 
     return Promise.all([
-      VideoCommentModel.count(getQuery(true)),
-      VideoCommentModel.findAll(getQuery(false))
-    ]).then(([ total, data ]) => ({ total, data }))
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
+    ]).then(([ rows, count ]) => {
+      return { total: count, data: rows }
+    })
   }
 
   static async listThreadsForApi (parameters: {
@@ -416,67 +294,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
     sort: string
     user?: MUserAccountId
   }) {
-    const { videoId, isVideoOwned, start, count, sort, user } = parameters
+    const { videoId, user } = parameters
 
-    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
+    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
 
-    const accountBlockedWhere = {
-      accountId: {
-        [Op.notIn]: Sequelize.literal(
-          '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
-        )
-      }
+    const commonOptions: ListVideoCommentsOptions = {
+      selectType: 'api',
+      videoId,
+      blockerAccountIds
     }
 
-    const queryList = {
-      offset: start,
-      limit: count,
-      order: getCommentSort(sort),
-      where: {
-        [Op.and]: [
-          {
-            videoId
-          },
-          {
-            inReplyToCommentId: null
-          },
-          {
-            [Op.or]: [
-              accountBlockedWhere,
-              {
-                accountId: null
-              }
-            ]
-          }
-        ]
-      }
+    const listOptions: ListVideoCommentsOptions = {
+      ...commonOptions,
+      ...pick(parameters, [ 'sort', 'start', 'count' ]),
+
+      isThread: true,
+      includeReplyCounters: true
     }
 
-    const findScopesList: (string | ScopeOptions)[] = [
-      ScopeNames.WITH_ACCOUNT_FOR_API,
-      {
-        method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
-      }
-    ]
+    const countOptions: ListVideoCommentsOptions = {
+      ...commonOptions,
 
-    const countScopesList: ScopeOptions[] = [
-      {
-        method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
-      }
-    ]
+      isThread: true
+    }
 
-    const notDeletedQueryCount = {
-      where: {
-        videoId,
-        deletedAt: null,
-        ...accountBlockedWhere
-      }
+    const notDeletedCountOptions: ListVideoCommentsOptions = {
+      ...commonOptions,
+
+      notDeleted: true
     }
 
     return Promise.all([
-      VideoCommentModel.scope(findScopesList).findAll(queryList),
-      VideoCommentModel.scope(countScopesList).count(queryList),
-      VideoCommentModel.count(notDeletedQueryCount)
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
     ]).then(([ rows, count, totalNotDeletedComments ]) => {
       return { total: count, data: rows, totalNotDeletedComments }
     })
@@ -484,54 +335,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
 
   static async listThreadCommentsForApi (parameters: {
     videoId: number
-    isVideoOwned: boolean
     threadId: number
     user?: MUserAccountId
   }) {
-    const { videoId, threadId, user, isVideoOwned } = parameters
+    const { user } = parameters
 
-    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
+    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
 
-    const query = {
-      order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
-      where: {
-        videoId,
-        [Op.and]: [
-          {
-            [Op.or]: [
-              { id: threadId },
-              { originCommentId: threadId }
-            ]
-          },
-          {
-            [Op.or]: [
-              {
-                accountId: {
-                  [Op.notIn]: Sequelize.literal(
-                    '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
-                  )
-                }
-              },
-              {
-                accountId: null
-              }
-            ]
-          }
-        ]
-      }
-    }
+    const queryOptions: ListVideoCommentsOptions = {
+      ...pick(parameters, [ 'videoId', 'threadId' ]),
 
-    const scopes: any[] = [
-      ScopeNames.WITH_ACCOUNT_FOR_API,
-      {
-        method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
-      }
-    ]
+      selectType: 'api',
+      sort: 'createdAt',
+
+      blockerAccountIds,
+      includeReplyCounters: true
+    }
 
     return Promise.all([
-      VideoCommentModel.count(query),
-      VideoCommentModel.scope(scopes).findAll(query)
-    ]).then(([ total, data ]) => ({ total, data }))
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
+    ]).then(([ rows, count ]) => {
+      return { total: count, data: rows }
+    })
   }
 
   static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@@ -559,31 +385,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
       .findAll(query)
   }
 
-  static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) {
-    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({
+  static async listAndCountByVideoForAP (parameters: {
+    video: MVideoImmutable
+    start: number
+    count: number
+  }) {
+    const { video } = parameters
+
+    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
+
+    const queryOptions: ListVideoCommentsOptions = {
+      ...pick(parameters, [ 'start', 'count' ]),
+
+      selectType: 'comment-only',
       videoId: video.id,
-      isVideoOwned: video.isOwned()
-    })
+      sort: 'createdAt',
 
-    const query = {
-      order: [ [ 'createdAt', 'ASC' ] ] as Order,
-      offset: start,
-      limit: count,
-      where: {
-        videoId: video.id,
-        accountId: {
-          [Op.notIn]: Sequelize.literal(
-            '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
-          )
-        }
-      },
-      transaction: t
+      blockerAccountIds
     }
 
     return Promise.all([
-      VideoCommentModel.count(query),
-      VideoCommentModel.findAll<MComment>(query)
-    ]).then(([ total, data ]) => ({ total, data }))
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
+    ]).then(([ rows, count ]) => {
+      return { total: count, data: rows }
+    })
   }
 
   static async listForFeed (parameters: {
@@ -592,97 +418,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
     videoId?: number
     accountId?: number
     videoChannelId?: number
-  }): Promise<MCommentOwnerVideoFeed[]> {
-    const serverActor = await getServerActor()
-    const { start, count, videoId, accountId, videoChannelId } = parameters
-
-    const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
-      '"VideoCommentModel"."accountId"',
-      [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
-    )
+  }) {
+    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
 
-    if (accountId) {
-      whereAnd.push({
-        accountId
-      })
-    }
+    const queryOptions: ListVideoCommentsOptions = {
+      ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
 
-    const accountWhere = {
-      [Op.and]: whereAnd
-    }
+      selectType: 'feed',
 
-    const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined
+      sort: '-createdAt',
+      onPublicVideo: true,
+      notDeleted: true,
 
-    const query = {
-      order: [ [ 'createdAt', 'DESC' ] ] as Order,
-      offset: start,
-      limit: count,
-      where: {
-        deletedAt: null,
-        accountId: accountWhere
-      },
-      include: [
-        {
-          attributes: [ 'name', 'uuid' ],
-          model: VideoModel.unscoped(),
-          required: true,
-          where: {
-            privacy: VideoPrivacy.PUBLIC
-          },
-          include: [
-            {
-              attributes: [ 'accountId' ],
-              model: VideoChannelModel.unscoped(),
-              required: true,
-              where: videoChannelWhere
-            }
-          ]
-        }
-      ]
+      blockerAccountIds
     }
 
-    if (videoId) query.where['videoId'] = videoId
-
-    return VideoCommentModel
-      .scope([ ScopeNames.WITH_ACCOUNT ])
-      .findAll(query)
+    return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
   }
 
   static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
-    const accountWhere = filter.onVideosOfAccount
-      ? { id: filter.onVideosOfAccount.id }
-      : {}
+    const queryOptions: ListVideoCommentsOptions = {
+      selectType: 'comment-only',
 
-    const query = {
-      limit: 1000,
-      where: {
-        deletedAt: null,
-        accountId: ofAccount.id
-      },
-      include: [
-        {
-          model: VideoModel,
-          required: true,
-          include: [
-            {
-              model: VideoChannelModel,
-              required: true,
-              include: [
-                {
-                  model: AccountModel,
-                  required: true,
-                  where: accountWhere
-                }
-              ]
-            }
-          ]
-        }
-      ]
+      accountId: ofAccount.id,
+      videoAccountOwnerId: filter.onVideosOfAccount?.id,
+
+      notDeleted: true,
+      count: 5000
     }
 
-    return VideoCommentModel
-      .scope([ ScopeNames.WITH_ACCOUNT ])
-      .findAll(query)
+    return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
   }
 
   static async getStats () {
@@ -750,9 +515,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
   }
 
   isOwned () {
-    if (!this.Account) {
-      return false
-    }
+    if (!this.Account) return false
 
     return this.Account.isOwned()
   }
@@ -906,22 +669,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
   }
 
   private static async buildBlockerAccountIds (options: {
-    videoId: number
-    isVideoOwned: boolean
-    user?: MUserAccountId
-  }) {
-    const { videoId, user, isVideoOwned } = options
+    user: MUserAccountId
+  }): Promise<number[]> {
+    const { user } = options
 
     const serverActor = await getServerActor()
     const blockerAccountIds = [ serverActor.Account.id ]
 
     if (user) blockerAccountIds.push(user.Account.id)
 
-    if (isVideoOwned) {
-      const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
-      if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
-    }
-
     return blockerAccountIds
   }
 }
index 9c4e6d078afbd5d61057194ce95180330e99995c..07bc13de1a9938d29f161047318e29712cb07aef 100644 (file)
@@ -21,6 +21,7 @@ import {
 import validator from 'validator'
 import { logger } from '@server/helpers/logger'
 import { extractVideo } from '@server/helpers/video'
+import { CONFIG } from '@server/initializers/config'
 import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
 import {
   getHLSPrivateFileUrl,
@@ -50,11 +51,9 @@ import {
 } from '../../initializers/constants'
 import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
-import { doesExist } from '../shared'
-import { parseAggregateResult, throwIfNotValid } from '../utils'
+import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
-import { CONFIG } from '@server/initializers/config'
 
 export enum ScopeNames {
   WITH_VIDEO = 'WITH_VIDEO',
@@ -266,7 +265,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
   static doesInfohashExist (infoHash: string) {
     const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
 
-    return doesExist(query, { infoHash })
+    return doesExist(this.sequelize, query, { infoHash })
   }
 
   static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
@@ -282,14 +281,14 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
                   'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
                   'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1'
 
-    return doesExist(query, { filename })
+    return doesExist(this.sequelize, query, { filename })
   }
 
   static async doesOwnedWebTorrentVideoFileExist (filename: string) {
     const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
                   `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
 
-    return doesExist(query, { filename })
+    return doesExist(this.sequelize, query, { filename })
   }
 
   static loadByFilename (filename: string) {
@@ -439,7 +438,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
     if (!element) return videoFile.save({ transaction })
 
     for (const k of Object.keys(videoFile.toJSON())) {
-      element[k] = videoFile[k]
+      element.set(k, videoFile[k])
     }
 
     return element.save({ transaction })
index da6b92c7a1e6672dffbc3135ff713cba2c8a2d06..c040e0fda69661a5cf5c041284e338971049fde8 100644 (file)
@@ -22,7 +22,7 @@ import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../help
 import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
 import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
 import { UserModel } from '../user/user'
-import { getSort, searchAttribute, throwIfNotValid } from '../utils'
+import { getSort, searchAttribute, throwIfNotValid } from '../shared'
 import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
 import { VideoChannelSyncModel } from './video-channel-sync'
 
index 7181b559989399a88c83c1ce6488c762ae5efa7d..48f4ed5a9cc7187787e0410cf9ac23568acd7840 100644 (file)
@@ -31,7 +31,7 @@ import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { AccountModel } from '../account/account'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
 import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
 import { VideoPlaylistModel } from './video-playlist'
 
index 8bbe54c49531ba91650c8d7ffbbaea75dd2e3e86..faf4bea789ac290dfcd19b62cdce7eb86eb20985 100644 (file)
@@ -21,12 +21,8 @@ import { activityPubCollectionPagination } from '@server/lib/activitypub/collect
 import { MAccountId, MChannelId } from '@server/types/models'
 import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
 import { buildUUID, uuidToShort } from '@shared/extra-utils'
+import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
 import { AttributesOnly } from '@shared/typescript-utils'
-import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
-import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
-import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
-import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
-import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import {
   isVideoPlaylistDescriptionValid,
@@ -53,7 +49,6 @@ import {
 } from '../../types/models/video/video-playlist'
 import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
 import { ActorModel } from '../actor/actor'
-import { setAsUpdated } from '../shared'
 import {
   buildServerIdsFollowedBy,
   buildTrigramSearchIndex,
@@ -61,8 +56,9 @@ import {
   createSimilarityAttribute,
   getPlaylistSort,
   isOutdated,
+  setAsUpdated,
   throwIfNotValid
-} from '../utils'
+} from '../shared'
 import { ThumbnailModel } from './thumbnail'
 import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
 import { VideoPlaylistElementModel } from './video-playlist-element'
@@ -641,7 +637,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
   }
 
   setAsRefreshed () {
-    return setAsUpdated('videoPlaylist', this.id)
+    return setAsUpdated({ sequelize: this.sequelize, table: 'videoPlaylist', id: this.id })
   }
 
   setVideosLength (videosLength: number) {
index f2190037ee8de32c9905f2ff46965f7ca4f1b520..b4de2b20fedd3d78b3a7042e665541ad3e310825 100644 (file)
@@ -7,7 +7,7 @@ import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models'
 import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
 import { ActorModel } from '../actor/actor'
-import { buildLocalActorIdsIn, throwIfNotValid } from '../utils'
+import { buildLocalActorIdsIn, throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 
 enum ScopeNames {
index 0386edf2842070ed59624b7aac83eed2e80a5aa1..a85c79c9f1d96c64c2542926aa964954bcfba0b8 100644 (file)
@@ -37,8 +37,7 @@ import {
   WEBSERVER
 } from '../../initializers/constants'
 import { VideoRedundancyModel } from '../redundancy/video-redundancy'
-import { doesExist } from '../shared'
-import { throwIfNotValid } from '../utils'
+import { doesExist, throwIfNotValid } from '../shared'
 import { VideoModel } from './video'
 
 @Table({
@@ -138,7 +137,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
   static doesInfohashExist (infoHash: string) {
     const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
 
-    return doesExist(query, { infoHash })
+    return doesExist(this.sequelize, query, { infoHash })
   }
 
   static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
@@ -237,7 +236,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
       `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
       `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
 
-    return doesExist(query, { videoUUID })
+    return doesExist(this.sequelize, query, { videoUUID })
   }
 
   assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
index 56cc45cfeda4d8a7b370cb669db023ae9b420230..1a10d2da229ec59d4d8484fc582cea90dbbc07e3 100644 (file)
@@ -32,7 +32,7 @@ import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFil
 import { VideoPathManager } from '@server/lib/video-path-manager'
 import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
 import { getServerActor } from '@server/models/application/application'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
 import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
 import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils'
 import {
@@ -103,10 +103,9 @@ import { VideoRedundancyModel } from '../redundancy/video-redundancy'
 import { ServerModel } from '../server/server'
 import { TrackerModel } from '../server/tracker'
 import { VideoTrackerModel } from '../server/video-tracker'
-import { setAsUpdated } from '../shared'
+import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
 import { UserModel } from '../user/user'
 import { UserVideoHistoryModel } from '../user/user-video-history'
-import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
 import { VideoViewModel } from '../view/video-view'
 import {
   videoFilesModelToFormattedJSON,
@@ -1871,7 +1870,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
   }
 
   setAsRefreshed (transaction?: Transaction) {
-    return setAsUpdated('video', this.id, transaction)
+    return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
   }
 
   // ---------------------------------------------------------------------------
index eb677912379ad27f2b97b9ad1c1c0b705fff89a6..1c1495022c5a5d0ce0cd9628ecd8db55295f7241 100644 (file)
@@ -148,7 +148,7 @@ describe('Test AP cleaner', function () {
   it('Should destroy server 3 internal shares and correctly clean them', async function () {
     this.timeout(20000)
 
-    const preCount = await servers[0].sql.getCount('videoShare')
+    const preCount = await servers[0].sql.getVideoShareCount()
     expect(preCount).to.equal(6)
 
     await servers[2].sql.deleteAll('videoShare')
@@ -156,7 +156,7 @@ describe('Test AP cleaner', function () {
     await waitJobs(servers)
 
     // Still 6 because we don't have remote shares on local videos
-    const postCount = await servers[0].sql.getCount('videoShare')
+    const postCount = await servers[0].sql.getVideoShareCount()
     expect(postCount).to.equal(6)
   })
 
@@ -185,7 +185,7 @@ describe('Test AP cleaner', function () {
     async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') {
       const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` +
         `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'`
-      const res = await servers[0].sql.selectQuery(query)
+      const res = await servers[0].sql.selectQuery<{ url: string }>(query)
 
       for (const rate of res) {
         const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`)
@@ -231,7 +231,7 @@ describe('Test AP cleaner', function () {
       const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` +
         `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'`
 
-      const res = await servers[0].sql.selectQuery(query)
+      const res = await servers[0].sql.selectQuery<{ url: string, videoUUID: string }>(query)
 
       for (const comment of res) {
         const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`)
index 908407b9a5a898a1e5ce823072373f467037eb29..73dfd489d49d2141c3b79a0280fd5682ec993505 100644 (file)
@@ -24,7 +24,7 @@ describe('Test server redundancy API validators', function () {
   // ---------------------------------------------------------------
 
   before(async function () {
-    this.timeout(80000)
+    this.timeout(160000)
 
     servers = await createMultipleServers(2)
 
index c0bb8d529f28f41a3a6b0a085d92f45abf9dc80a..f6959b83cf6519c43794c7d9956afcfd1191acaf 100644 (file)
@@ -78,9 +78,15 @@ describe('Fast restream in live', function () {
       const video = await server.videos.get({ id: liveId })
       expect(video.streamingPlaylists).to.have.lengthOf(1)
 
-      await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
-      await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
-      await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
+      try {
+        await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
+        await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
+        await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
+      } catch (err) {
+        // FIXME: try to debug error in CI "Unexpected end of JSON input"
+        console.error(err)
+        throw err
+      }
 
       await wait(100)
     }
@@ -129,7 +135,7 @@ describe('Fast restream in live', function () {
     await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
   })
 
-  it('Should correctly fast reastream in a permanent live with and without save replay', async function () {
+  it('Should correctly fast restream in a permanent live with and without save replay', async function () {
     this.timeout(480000)
 
     // A test can take a long time, so prefer to run them in parallel
index b127a7a31e4a0fc372f3e4ceb21aefa70fc15cb1..c7b9b5fb03a6d8bf4094653d604bf4bd7317cf1d 100644 (file)
@@ -34,7 +34,7 @@ describe('Test moderation notifications', function () {
   let emails: object[] = []
 
   before(async function () {
-    this.timeout(120000)
+    this.timeout(50000)
 
     const res = await prepareNotificationsTest(3)
     emails = res.emails
@@ -60,7 +60,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should not send a notification to moderators on local abuse reported by an admin', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -72,7 +72,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on local video abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -84,7 +84,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on remote video abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -99,7 +99,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on local comment abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -118,7 +118,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on remote comment abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const name = 'video for abuse ' + buildUUID()
       const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -140,7 +140,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on local account abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const username = 'user' + new Date().getTime()
       const { account } = await servers[0].users.create({ username, password: 'donald' })
@@ -153,7 +153,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a notification to moderators on remote account abuse', async function () {
-      this.timeout(20000)
+      this.timeout(50000)
 
       const username = 'user' + new Date().getTime()
       const tmpToken = await servers[0].users.generateUserAndToken(username)
@@ -512,10 +512,14 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should not send video publish notification if auto-blacklisted', async function () {
+      this.timeout(120000)
+
       await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' })
     })
 
     it('Should not send a local user subscription notification if auto-blacklisted', async function () {
+      this.timeout(120000)
+
       await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' })
     })
 
@@ -524,7 +528,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send video published and unblacklist after video unblacklisted', async function () {
-      this.timeout(40000)
+      this.timeout(120000)
 
       await servers[0].blacklist.remove({ videoId: uuid })
 
@@ -537,10 +541,14 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should send a local user subscription notification after removed from blacklist', async function () {
+      this.timeout(120000)
+
       await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' })
     })
 
     it('Should send a remote user subscription notification after removed from blacklist', async function () {
+      this.timeout(120000)
+
       await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' })
     })
 
@@ -576,7 +584,7 @@ describe('Test moderation notifications', function () {
     })
 
     it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () {
-      this.timeout(40000)
+      this.timeout(120000)
 
       // In 2 seconds
       const updateAt = new Date(new Date().getTime() + 2000)
index 71ad35a4346edbef1446c4b261a534f6840480cb..869d437d584900459fe30cd767ac915d5826e542 100644 (file)
@@ -120,7 +120,7 @@ describe('Object storage for video static file privacy', function () {
     // ---------------------------------------------------------------------------
 
     it('Should upload a private video and have appropriate object storage ACL', async function () {
-      this.timeout(60000)
+      this.timeout(120000)
 
       {
         const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
@@ -138,7 +138,7 @@ describe('Object storage for video static file privacy', function () {
     })
 
     it('Should upload a public video and have appropriate object storage ACL', async function () {
-      this.timeout(60000)
+      this.timeout(120000)
 
       const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED })
       await waitJobs([ server ])
index 643f1a531f8dbd779403ceaf4bc1e6704816ce63..0313845ef0fb2fa94593c9f3dab6b9957726fe47 100644 (file)
@@ -1,3 +1,4 @@
+import './oauth'
 import './two-factor'
 import './user-subscriptions'
 import './user-videos'
diff --git a/server/tests/api/users/oauth.ts b/server/tests/api/users/oauth.ts
new file mode 100644 (file)
index 0000000..6a3da5e
--- /dev/null
@@ -0,0 +1,192 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { wait } from '@shared/core-utils'
+import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models'
+import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
+
+describe('Test oauth', function () {
+  let server: PeerTubeServer
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await createSingleServer(1, {
+      rates_limit: {
+        login: {
+          max: 30
+        }
+      }
+    })
+
+    await setAccessTokensToServers([ server ])
+  })
+
+  describe('OAuth client', function () {
+
+    function expectInvalidClient (body: PeerTubeProblemDocument) {
+      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
+      expect(body.error).to.contain('client is invalid')
+      expect(body.type.startsWith('https://')).to.be.true
+      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
+    }
+
+    it('Should create a new client')
+
+    it('Should return the first client')
+
+    it('Should remove the last client')
+
+    it('Should not login with an invalid client id', async function () {
+      const client = { id: 'client', secret: server.store.client.secret }
+      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidClient(body)
+    })
+
+    it('Should not login with an invalid client secret', async function () {
+      const client = { id: server.store.client.id, secret: 'coucou' }
+      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidClient(body)
+    })
+  })
+
+  describe('Login', function () {
+
+    function expectInvalidCredentials (body: PeerTubeProblemDocument) {
+      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
+      expect(body.error).to.contain('credentials are invalid')
+      expect(body.type.startsWith('https://')).to.be.true
+      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
+    }
+
+    it('Should not login with an invalid username', async function () {
+      const user = { username: 'captain crochet', password: server.store.user.password }
+      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidCredentials(body)
+    })
+
+    it('Should not login with an invalid password', async function () {
+      const user = { username: server.store.user.username, password: 'mew_three' }
+      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidCredentials(body)
+    })
+
+    it('Should be able to login', async function () {
+      await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
+    })
+
+    it('Should be able to login with an insensitive username', async function () {
+      const user = { username: 'RoOt', password: server.store.user.password }
+      await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
+
+      const user2 = { username: 'rOoT', password: server.store.user.password }
+      await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
+
+      const user3 = { username: 'ROOt', password: server.store.user.password }
+      await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
+    })
+  })
+
+  describe('Logout', function () {
+
+    it('Should logout (revoke token)', async function () {
+      await server.login.logout({ token: server.accessToken })
+    })
+
+    it('Should not be able to get the user information', async function () {
+      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should not be able to upload a video', async function () {
+      await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should be able to login again', async function () {
+      const body = await server.login.login()
+      server.accessToken = body.access_token
+      server.refreshToken = body.refresh_token
+    })
+
+    it('Should be able to get my user information again', async function () {
+      await server.users.getMyInfo()
+    })
+
+    it('Should have an expired access token', async function () {
+      this.timeout(60000)
+
+      await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
+      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
+
+      await killallServers([ server ])
+      await server.run()
+
+      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should not be able to refresh an access token with an expired refresh token', async function () {
+      await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+
+    it('Should refresh the token', async function () {
+      this.timeout(50000)
+
+      const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
+      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
+
+      await killallServers([ server ])
+      await server.run()
+
+      const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
+      server.accessToken = res.body.access_token
+      server.refreshToken = res.body.refresh_token
+    })
+
+    it('Should be able to get my user information again', async function () {
+      await server.users.getMyInfo()
+    })
+  })
+
+  describe('Custom token lifetime', function () {
+    before(async function () {
+      this.timeout(120_000)
+
+      await server.kill()
+      await server.run({
+        oauth2: {
+          token_lifetime: {
+            access_token: '2 seconds',
+            refresh_token: '2 seconds'
+          }
+        }
+      })
+    })
+
+    it('Should have a very short access token lifetime', async function () {
+      this.timeout(50000)
+
+      const { access_token: accessToken } = await server.login.login()
+      await server.users.getMyInfo({ token: accessToken })
+
+      await wait(3000)
+      await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should have a very short refresh token lifetime', async function () {
+      this.timeout(50000)
+
+      const { refresh_token: refreshToken } = await server.login.login()
+      await server.login.refreshToken({ refreshToken })
+
+      await wait(3000)
+      await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index 421b3ce1682306b43f168afb4c7eb89fb5a36cc5..93e2e489a3d4f06b39e0810f1d8848a282f6590f 100644 (file)
@@ -2,15 +2,8 @@
 
 import { expect } from 'chai'
 import { testImage } from '@server/tests/shared'
-import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
-import {
-  cleanupTests,
-  createSingleServer,
-  killallServers,
-  makePutBodyRequest,
-  PeerTubeServer,
-  setAccessTokensToServers
-} from '@shared/server-commands'
+import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
+import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
 
 describe('Test users', function () {
   let server: PeerTubeServer
@@ -39,166 +32,6 @@ describe('Test users', function () {
     await server.plugins.install({ npmName: 'peertube-theme-background-red' })
   })
 
-  describe('OAuth client', function () {
-    it('Should create a new client')
-
-    it('Should return the first client')
-
-    it('Should remove the last client')
-
-    it('Should not login with an invalid client id', async function () {
-      const client = { id: 'client', secret: server.store.client.secret }
-      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
-      expect(body.error).to.contain('client is invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
-    })
-
-    it('Should not login with an invalid client secret', async function () {
-      const client = { id: server.store.client.id, secret: 'coucou' }
-      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
-      expect(body.error).to.contain('client is invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
-    })
-  })
-
-  describe('Login', function () {
-
-    it('Should not login with an invalid username', async function () {
-      const user = { username: 'captain crochet', password: server.store.user.password }
-      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
-      expect(body.error).to.contain('credentials are invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
-    })
-
-    it('Should not login with an invalid password', async function () {
-      const user = { username: server.store.user.username, password: 'mew_three' }
-      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
-      expect(body.error).to.contain('credentials are invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
-    })
-
-    it('Should not be able to upload a video', async function () {
-      token = 'my_super_token'
-
-      await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to follow', async function () {
-      token = 'my_super_token'
-
-      await server.follows.follow({
-        hosts: [ 'http://example.com' ],
-        token,
-        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
-      })
-    })
-
-    it('Should not be able to unfollow')
-
-    it('Should be able to login', async function () {
-      const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
-
-      token = body.access_token
-    })
-
-    it('Should be able to login with an insensitive username', async function () {
-      const user = { username: 'RoOt', password: server.store.user.password }
-      await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
-
-      const user2 = { username: 'rOoT', password: server.store.user.password }
-      await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
-
-      const user3 = { username: 'ROOt', password: server.store.user.password }
-      await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
-    })
-  })
-
-  describe('Logout', function () {
-    it('Should logout (revoke token)', async function () {
-      await server.login.logout({ token: server.accessToken })
-    })
-
-    it('Should not be able to get the user information', async function () {
-      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to upload a video', async function () {
-      await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to rate a video', async function () {
-      const path = '/api/v1/videos/'
-      const data = {
-        rating: 'likes'
-      }
-
-      const options = {
-        url: server.url,
-        path: path + videoId,
-        token: 'wrong token',
-        fields: data,
-        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
-      }
-      await makePutBodyRequest(options)
-    })
-
-    it('Should be able to login again', async function () {
-      const body = await server.login.login()
-      server.accessToken = body.access_token
-      server.refreshToken = body.refresh_token
-    })
-
-    it('Should be able to get my user information again', async function () {
-      await server.users.getMyInfo()
-    })
-
-    it('Should have an expired access token', async function () {
-      this.timeout(60000)
-
-      await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
-      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
-
-      await killallServers([ server ])
-      await server.run()
-
-      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to refresh an access token with an expired refresh token', async function () {
-      await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-    })
-
-    it('Should refresh the token', async function () {
-      this.timeout(50000)
-
-      const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
-      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
-
-      await killallServers([ server ])
-      await server.run()
-
-      const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
-      server.accessToken = res.body.access_token
-      server.refreshToken = res.body.refresh_token
-    })
-
-    it('Should be able to get my user information again', async function () {
-      await server.users.getMyInfo()
-    })
-  })
-
   describe('Creating a user', function () {
 
     it('Should be able to create a new user', async function () {
@@ -512,6 +345,7 @@ describe('Test users', function () {
   })
 
   describe('Updating another user', function () {
+
     it('Should be able to update another user', async function () {
       await server.users.update({
         userId,
@@ -562,13 +396,6 @@ describe('Test users', function () {
     })
   })
 
-  describe('Video blacklists', function () {
-
-    it('Should be able to list my video blacklist', async function () {
-      await server.blacklist.list({ token: userToken })
-    })
-  })
-
   describe('Remove a user', function () {
 
     before(async function () {
@@ -653,8 +480,9 @@ describe('Test users', function () {
   })
 
   describe('User blocking', function () {
-    let user16Id
-    let user16AccessToken
+    let user16Id: number
+    let user16AccessToken: string
+
     const user16 = {
       username: 'user_16',
       password: 'my super password'
index 91291524d38544f11898bdbbb5c8f51230b1f677..dd483f95ecbdd0d654e409648e97256eef4ebad0 100644 (file)
@@ -307,6 +307,7 @@ describe('Test channel synchronizations', function () {
     })
   }
 
-  runSuite('youtube-dl')
+  // FIXME: suite is broken with youtube-dl
+  // runSuite('youtube-dl')
   runSuite('yt-dlp')
 })
index dc47f8a4a41c83b9b3eef8835d8873b5b6aa6f38..e077cbf73569c2653012a9c1b13165fe7a736f27 100644 (file)
@@ -38,6 +38,8 @@ describe('Test video comments', function () {
     await setDefaultAccountAvatar(server)
 
     userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
+    await setDefaultChannelAvatar(server, 'user1_channel')
+    await setDefaultAccountAvatar(server, userAccessTokenServer1)
 
     command = server.comments
   })
@@ -232,16 +234,34 @@ describe('Test video comments', function () {
       await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
 
       const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
-      expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1)
+      expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1)
+      expect(tree.comment.totalReplies).to.equal(2)
     })
   })
 
   describe('All instance comments', function () {
 
     it('Should list instance comments as admin', async function () {
-      const { data } = await command.listForAdmin({ start: 0, count: 1 })
+      {
+        const { data, total } = await command.listForAdmin({ start: 0, count: 1 })
 
-      expect(data[0].text).to.equal('my second answer to thread 4')
+        expect(total).to.equal(7)
+        expect(data).to.have.lengthOf(1)
+        expect(data[0].text).to.equal('my second answer to thread 4')
+        expect(data[0].account.name).to.equal('root')
+        expect(data[0].account.displayName).to.equal('root')
+        expect(data[0].account.avatars).to.have.lengthOf(2)
+      }
+
+      {
+        const { data, total } = await command.listForAdmin({ start: 1, count: 2 })
+
+        expect(total).to.equal(7)
+        expect(data).to.have.lengthOf(2)
+
+        expect(data[0].account.avatars).to.have.lengthOf(2)
+        expect(data[1].account.avatars).to.have.lengthOf(2)
+      }
     })
 
     it('Should filter instance comments by isLocal', async function () {
index d14587c38608d2fd161279e8c90af3e569dea543..cadd02e8d624f573693587fd464f7e5787594afd 100644 (file)
@@ -30,7 +30,7 @@ describe('Official plugin auto-block videos', function () {
   let port: number
 
   before(async function () {
-    this.timeout(60000)
+    this.timeout(120000)
 
     servers = await createMultipleServers(2)
     await setAccessTokensToServers(servers)
index 440b58bfd4476c76181748176a95c755e0125a82..cfed76e8856c7a2931bd66b842a4f3e2beb2bd8a 100644 (file)
@@ -21,7 +21,7 @@ describe('Official plugin auto-mute', function () {
   let port: number
 
   before(async function () {
-    this.timeout(30000)
+    this.timeout(120000)
 
     servers = await createMultipleServers(2)
     await setAccessTokensToServers(servers)
index 906dab1a3aa6d13a212354f97d74f41bf6e06cbc..7345f728a88a114315bdb5e4a96fcf8cd256d010 100644 (file)
@@ -189,7 +189,7 @@ describe('Test syndication feeds', () => {
         const jsonObj = JSON.parse(json)
         expect(jsonObj.items.length).to.be.equal(1)
         expect(jsonObj.items[0].title).to.equal('my super name for server 1')
-        expect(jsonObj.items[0].author.name).to.equal('root')
+        expect(jsonObj.items[0].author.name).to.equal('Main root channel')
       }
 
       {
@@ -197,7 +197,7 @@ describe('Test syndication feeds', () => {
         const jsonObj = JSON.parse(json)
         expect(jsonObj.items.length).to.be.equal(1)
         expect(jsonObj.items[0].title).to.equal('user video')
-        expect(jsonObj.items[0].author.name).to.equal('john')
+        expect(jsonObj.items[0].author.name).to.equal('Main john channel')
       }
 
       for (const server of servers) {
@@ -223,7 +223,7 @@ describe('Test syndication feeds', () => {
         const jsonObj = JSON.parse(json)
         expect(jsonObj.items.length).to.be.equal(1)
         expect(jsonObj.items[0].title).to.equal('my super name for server 1')
-        expect(jsonObj.items[0].author.name).to.equal('root')
+        expect(jsonObj.items[0].author.name).to.equal('Main root channel')
       }
 
       {
@@ -231,7 +231,7 @@ describe('Test syndication feeds', () => {
         const jsonObj = JSON.parse(json)
         expect(jsonObj.items.length).to.be.equal(1)
         expect(jsonObj.items[0].title).to.equal('user video')
-        expect(jsonObj.items[0].author.name).to.equal('john')
+        expect(jsonObj.items[0].author.name).to.equal('Main john channel')
       }
 
       for (const server of servers) {
index c65b8d3a8f618af61d92712a947fc348549966ec..58bc27661b321bf6d6d46391473f1a7f7f69864b 100644 (file)
@@ -33,7 +33,17 @@ async function register ({
           username: 'kefka',
           email: 'kefka@example.com',
           role: 0,
-          displayName: 'Kefka Palazzo'
+          displayName: 'Kefka Palazzo',
+          adminFlags: 1,
+          videoQuota: 42000,
+          videoQuotaDaily: 42100,
+
+          // Always use new value except for videoQuotaDaily field
+          userUpdater: ({ fieldName, currentValue, newValue }) => {
+            if (fieldName === 'videoQuotaDaily') return currentValue
+
+            return newValue
+          }
         })
       },
       hookTokenValidity: (options) => {
index 3e848c49e0f4a9b02e121d88ffaa769cb4680b35..b10177b452164b6384248e5d2ff6e1679a5734df 100644 (file)
@@ -76,6 +76,12 @@ async function register ({
       return res.json({ serverConfig })
     })
 
+    router.get('/server-listening-config', async (req, res) => {
+      const config = await peertubeHelpers.config.getServerListeningConfig()
+
+      return res.json({ config })
+    })
+
     router.get('/static-route', async (req, res) => {
       const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute()
 
index ceab7b60de3a96284e0ed688aaac6b9b2da9aebf..fad5abf60e4831f00975a230626f06fbd081d513 100644 (file)
@@ -33,7 +33,18 @@ async function register ({
       if (body.id === 'laguna' && body.password === 'laguna password') {
         return Promise.resolve({
           username: 'laguna',
-          email: 'laguna@example.com'
+          email: 'laguna@example.com',
+          displayName: 'Laguna Loire',
+          adminFlags: 1,
+          videoQuota: 42000,
+          videoQuotaDaily: 42100,
+
+          // Always use new value except for videoQuotaDaily field
+          userUpdater: ({ fieldName, currentValue, newValue }) => {
+            if (fieldName === 'videoQuotaDaily') return currentValue
+
+            return newValue
+          }
         })
       }
 
index 19dccf26e8bff975ff23360b281071ab96749db3..19ba9f2784c01ff4e56cd506bee5b3fd1674331a 100644 (file)
@@ -250,7 +250,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
 
   registerHook({
     target: 'filter:api.download.video.allowed.result',
-    handler: (result, params) => {
+    handler: async (result, params) => {
+      const loggedInUser = await peertubeHelpers.user.getAuthUser(params.res)
+      if (loggedInUser) return { allowed: true }
+
       if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
         return { allowed: false, errorMessage: 'Cao Cao' }
       }
index 437777e90e7a2dfb72f4a975f3809b074319a228..e600f958f89bb68657713cadbf64f17387d2abc0 100644 (file)
@@ -2,7 +2,7 @@
 
 import { expect } from 'chai'
 import { wait } from '@shared/core-utils'
-import { HttpStatusCode, UserRole } from '@shared/models'
+import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
 import {
   cleanupTests,
   createSingleServer,
@@ -51,6 +51,7 @@ describe('Test external auth plugins', function () {
 
   let kefkaAccessToken: string
   let kefkaRefreshToken: string
+  let kefkaId: number
 
   let externalAuthToken: string
 
@@ -156,6 +157,9 @@ describe('Test external auth plugins', function () {
       expect(body.account.displayName).to.equal('cyan')
       expect(body.email).to.equal('cyan@example.com')
       expect(body.role.id).to.equal(UserRole.USER)
+      expect(body.adminFlags).to.equal(UserAdminFlag.NONE)
+      expect(body.videoQuota).to.equal(5242880)
+      expect(body.videoQuotaDaily).to.equal(-1)
     }
   })
 
@@ -178,6 +182,11 @@ describe('Test external auth plugins', function () {
       expect(body.account.displayName).to.equal('Kefka Palazzo')
       expect(body.email).to.equal('kefka@example.com')
       expect(body.role.id).to.equal(UserRole.ADMINISTRATOR)
+      expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)
+      expect(body.videoQuota).to.equal(42000)
+      expect(body.videoQuotaDaily).to.equal(42100)
+
+      kefkaId = body.id
     }
   })
 
@@ -240,6 +249,37 @@ describe('Test external auth plugins', function () {
     expect(body.role.id).to.equal(UserRole.USER)
   })
 
+  it('Should login Kefka and update the profile', async function () {
+    {
+      await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 })
+      await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' })
+
+      const body = await server.users.getMyInfo({ token: kefkaAccessToken })
+      expect(body.username).to.equal('kefka')
+      expect(body.account.displayName).to.equal('kefka updated')
+      expect(body.videoQuota).to.equal(43000)
+      expect(body.videoQuotaDaily).to.equal(43100)
+    }
+
+    {
+      const res = await loginExternal({
+        server,
+        npmName: 'test-external-auth-one',
+        authName: 'external-auth-2',
+        username: 'kefka'
+      })
+
+      kefkaAccessToken = res.access_token
+      kefkaRefreshToken = res.refresh_token
+
+      const body = await server.users.getMyInfo({ token: kefkaAccessToken })
+      expect(body.username).to.equal('kefka')
+      expect(body.account.displayName).to.equal('Kefka Palazzo')
+      expect(body.videoQuota).to.equal(42000)
+      expect(body.videoQuotaDaily).to.equal(43100)
+    }
+  })
+
   it('Should not update an external auth email', async function () {
     await server.users.updateMe({
       token: cyanAccessToken,
index 083fd43ca4bb5e573aa7f5931cd09bd11d926cd2..6724b3bf86dcfcc7b6fd3946be8fbc4bd70b8ad9 100644 (file)
@@ -430,6 +430,7 @@ describe('Test plugin filter hooks', function () {
 
   describe('Download hooks', function () {
     const downloadVideos: VideoDetails[] = []
+    let downloadVideo2Token: string
 
     before(async function () {
       this.timeout(120000)
@@ -459,6 +460,8 @@ describe('Test plugin filter hooks', function () {
       for (const uuid of uuids) {
         downloadVideos.push(await servers[0].videos.get({ id: uuid }))
       }
+
+      downloadVideo2Token = await servers[0].videoToken.getVideoFileToken({ videoId: downloadVideos[2].uuid })
     })
 
     it('Should run filter:api.download.torrent.allowed.result', async function () {
@@ -471,32 +474,42 @@ describe('Test plugin filter hooks', function () {
 
     it('Should run filter:api.download.video.allowed.result', async function () {
       {
-        const res = await makeRawRequest({ url: downloadVideos[1].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+        const refused = downloadVideos[1].files[0].fileDownloadUrl
+        const allowed = [
+          downloadVideos[0].files[0].fileDownloadUrl,
+          downloadVideos[2].files[0].fileDownloadUrl
+        ]
+
+        const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
         expect(res.body.error).to.equal('Cao Cao')
 
-        await makeRawRequest({ url: downloadVideos[0].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 })
-        await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 })
+        for (const url of allowed) {
+          await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
+          await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
+        }
       }
 
       {
-        const res = await makeRawRequest({
-          url: downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl,
-          expectedStatus: HttpStatusCode.FORBIDDEN_403
-        })
+        const refused = downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl
 
-        expect(res.body.error).to.equal('Sun Jian')
+        const allowed = [
+          downloadVideos[2].files[0].fileDownloadUrl,
+          downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
+          downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl
+        ]
 
-        await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 })
+        // Only streaming playlist is refuse
+        const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+        expect(res.body.error).to.equal('Sun Jian')
 
-        await makeRawRequest({
-          url: downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
-          expectedStatus: HttpStatusCode.OK_200
-        })
+        // But not we there is a user in res
+        await makeRawRequest({ url: refused, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 })
+        await makeRawRequest({ url: refused, query: { videoFileToken: downloadVideo2Token }, expectedStatus: HttpStatusCode.OK_200 })
 
-        await makeRawRequest({
-          url: downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl,
-          expectedStatus: HttpStatusCode.OK_200
-        })
+        // Other files work
+        for (const url of allowed) {
+          await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
+        }
       }
     })
   })
index fc24a56564b2a5f638446257a333a028c5752b3e..10155c28b667be5087fc4d5b28e56b75c2905d82 100644 (file)
@@ -13,6 +13,7 @@ describe('Test id and pass auth plugins', function () {
 
   let lagunaAccessToken: string
   let lagunaRefreshToken: string
+  let lagunaId: number
 
   before(async function () {
     this.timeout(30000)
@@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () {
       const body = await server.users.getMyInfo({ token: lagunaAccessToken })
 
       expect(body.username).to.equal('laguna')
-      expect(body.account.displayName).to.equal('laguna')
+      expect(body.account.displayName).to.equal('Laguna Loire')
       expect(body.role.id).to.equal(UserRole.USER)
+
+      lagunaId = body.id
     }
   })
 
@@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () {
     expect(body.role.id).to.equal(UserRole.MODERATOR)
   })
 
+  it('Should login Laguna and update the profile', async function () {
+    {
+      await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 })
+      await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' })
+
+      const body = await server.users.getMyInfo({ token: lagunaAccessToken })
+      expect(body.username).to.equal('laguna')
+      expect(body.account.displayName).to.equal('laguna updated')
+      expect(body.videoQuota).to.equal(43000)
+      expect(body.videoQuotaDaily).to.equal(43100)
+    }
+
+    {
+      const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } })
+      lagunaAccessToken = body.access_token
+      lagunaRefreshToken = body.refresh_token
+    }
+
+    {
+      const body = await server.users.getMyInfo({ token: lagunaAccessToken })
+      expect(body.username).to.equal('laguna')
+      expect(body.account.displayName).to.equal('Laguna Loire')
+      expect(body.videoQuota).to.equal(42000)
+      expect(body.videoQuotaDaily).to.equal(43100)
+    }
+  })
+
   it('Should reject token of laguna by the plugin hook', async function () {
     this.timeout(10000)
 
@@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () {
     await server.servers.waitUntilLog('valid username')
 
     await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-    await server.servers.waitUntilLog('valid display name')
+    await server.servers.waitUntilLog('valid displayName')
 
     await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
     await server.servers.waitUntilLog('valid role')
index 038e3f0d6cdd87a11d3e7ec9447bf91477e97adc..e25992723d0759c2f043641b0d909c1b4949d4e5 100644 (file)
@@ -64,6 +64,18 @@ describe('Test plugin helpers', function () {
       await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`)
     })
 
+    it('Should have the correct listening config', async function () {
+      const res = await makeGetRequest({
+        url: servers[0].url,
+        path: '/plugins/test-four/router/server-listening-config',
+        expectedStatus: HttpStatusCode.OK_200
+      })
+
+      expect(res.body.config).to.exist
+      expect(res.body.config.hostname).to.equal('::')
+      expect(res.body.config.port).to.equal(servers[0].port)
+    })
+
     it('Should have the correct config', async function () {
       const res = await makeGetRequest({
         url: servers[0].url,
index 3738ffc47dd8d716078831e17cbff1405a072347..6fea4dac24bffc8a20d3fb6a7167d375c87ef54d 100644 (file)
@@ -1,4 +1,3 @@
-
 import { OutgoingHttpHeaders } from 'http'
 import { RegisterServerAuthExternalOptions } from '@server/types'
 import {
@@ -10,6 +9,7 @@ import {
   MChannelBannerAccountDefault,
   MChannelSyncChannel,
   MStreamingPlaylist,
+  MUserAccountUrl,
   MVideoChangeOwnershipFull,
   MVideoFile,
   MVideoFormattableDetails,
@@ -187,6 +187,10 @@ declare module 'express' {
         actor: MActorAccountChannelId
       }
 
+      videoFileToken?: {
+        user: MUserAccountUrl
+      }
+
       authenticated?: boolean
 
       registeredPlugin?: RegisteredPlugin
diff --git a/server/types/lib.d.ts b/server/types/lib.d.ts
new file mode 100644 (file)
index 0000000..c901e20
--- /dev/null
@@ -0,0 +1,12 @@
+type ObjectKeys<T> =
+  T extends object
+    ? `${Exclude<keyof T, symbol>}`[]
+    : T extends number
+      ? []
+      : T extends any | string
+        ? string[]
+        : never
+
+interface ObjectConstructor {
+  keys<T> (o: T): ObjectKeys<T>
+}
index 79c18c406c93c4c69629689bba9d0010cbf37ca1..e10968c20f507be8404182eac8f971e169d1b366 100644 (file)
@@ -1,14 +1,33 @@
 import express from 'express'
-import { UserRole } from '@shared/models'
+import { UserAdminFlag, UserRole } from '@shared/models'
 import { MOAuthToken, MUser } from '../models'
 
 export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
 
+export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily'
+
 export interface RegisterServerAuthenticatedResult {
+  // Update the user profile if it already exists
+  // Default behaviour is no update
+  // Introduced in PeerTube >= 5.1
+  userUpdater?: <T> (options: {
+    fieldName: AuthenticatedResultUpdaterFieldName
+    currentValue: T
+    newValue: T
+  }) => T
+
   username: string
   email: string
   role?: UserRole
   displayName?: string
+
+  // PeerTube >= 5.1
+  adminFlags?: UserAdminFlag
+
+  // PeerTube >= 5.1
+  videoQuota?: number
+  // PeerTube >= 5.1
+  videoQuotaDaily?: number
 }
 
 export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
index 1e2bd830ebaf7390c3e71c397bbeef23a6eb97c2..df419fff47239576630c6f7dffcc2dc4e58f3379 100644 (file)
@@ -71,6 +71,9 @@ export type PeerTubeHelpers = {
   config: {
     getWebserverUrl: () => string
 
+    // PeerTube >= 5.1
+    getServerListeningConfig: () => { hostname: string, port: number }
+
     getServerConfig: () => Promise<ServerConfig>
   }
 
index 3784969b5cba82eaea695714d48c457bbda85620..96bcc945e895e195eab3317d0cea83290064692e 100644 (file)
@@ -1,3 +1,4 @@
+import { RegisteredExternalAuthConfig } from '@shared/models'
 import { HookType } from '../../models/plugins/hook-type.enum'
 import { isCatchable, isPromise } from '../common/promises'
 
@@ -49,7 +50,12 @@ async function internalRunHook <T> (options: {
   return result
 }
 
+function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) {
+  return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
+}
+
 export {
   getHookType,
-  internalRunHook
+  internalRunHook,
+  getExternalAuthHref
 }
index 823fc9e388d6a7dcc0b3611da039c4d79824eb66..35cc2253f7d09ae2cff85637d9f13212fee29a16 100644 (file)
@@ -13,101 +13,87 @@ export class SQLCommand extends AbstractCommand {
     return seq.query(`DELETE FROM "${table}"`, options)
   }
 
-  async getCount (table: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
-
-    const [ { total } ] = await seq.query<{ total: string }>(`SELECT COUNT(*) as total FROM "${table}"`, options)
+  async getVideoShareCount () {
+    const [ { total } ] = await this.selectQuery<{ total: string }>(`SELECT COUNT(*) as total FROM "videoShare"`)
     if (total === null) return 0
 
     return parseInt(total, 10)
   }
 
   async getInternalFileUrl (fileId: number) {
-    return this.selectQuery(`SELECT "fileUrl" FROM "videoFile" WHERE id = ${fileId}`)
-      .then(rows => rows[0].fileUrl as string)
+    return this.selectQuery<{ fileUrl: string }>(`SELECT "fileUrl" FROM "videoFile" WHERE id = :fileId`, { fileId })
+      .then(rows => rows[0].fileUrl)
   }
 
   setActorField (to: string, field: string, value: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options)
+    return this.updateQuery(`UPDATE actor SET ${this.escapeColumnName(field)} = :value WHERE url = :to`, { value, to })
   }
 
   setVideoField (uuid: string, field: string, value: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
+    return this.updateQuery(`UPDATE video SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
   }
 
   setPlaylistField (uuid: string, field: string, value: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE "videoPlaylist" SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
+    return this.updateQuery(`UPDATE "videoPlaylist" SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
   }
 
   async countVideoViewsOf (uuid: string) {
-    const seq = this.getSequelize()
-
     const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' +
-      `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = '${uuid}'`
-
-    const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
-    const [ { total } ] = await seq.query<{ total: number }>(query, options)
+      `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = :uuid`
 
+    const [ { total } ] = await this.selectQuery<{ total: number }>(query, { uuid })
     if (!total) return 0
 
     return forceNumber(total)
   }
 
   getActorImage (filename: string) {
-    return this.selectQuery(`SELECT * FROM "actorImage" WHERE filename = '${filename}'`)
+    return this.selectQuery<{ width: number, height: number }>(`SELECT * FROM "actorImage" WHERE filename = :filename`, { filename })
       .then(rows => rows[0])
   }
 
-  selectQuery (query: string) {
-    const seq = this.getSequelize()
-    const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
+  // ---------------------------------------------------------------------------
 
-    return seq.query<any>(query, options)
+  setPluginVersion (pluginName: string, newVersion: string) {
+    return this.setPluginField(pluginName, 'version', newVersion)
   }
 
-  updateQuery (query: string) {
-    const seq = this.getSequelize()
-    const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE }
+  setPluginLatestVersion (pluginName: string, newVersion: string) {
+    return this.setPluginField(pluginName, 'latestVersion', newVersion)
+  }
 
-    return seq.query(query, options)
+  setPluginField (pluginName: string, field: string, value: string) {
+    return this.updateQuery(
+      `UPDATE "plugin" SET ${this.escapeColumnName(field)} = :value WHERE "name" = :pluginName`,
+      { pluginName, value }
+    )
   }
 
   // ---------------------------------------------------------------------------
 
-  setPluginField (pluginName: string, field: string, value: string) {
+  selectQuery <T extends object> (query: string, replacements: { [id: string]: string | number } = {}) {
     const seq = this.getSequelize()
+    const options = {
+      type: QueryTypes.SELECT as QueryTypes.SELECT,
+      replacements
+    }
 
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE "plugin" SET "${field}" = '${value}' WHERE "name" = '${pluginName}'`, options)
+    return seq.query<T>(query, options)
   }
 
-  setPluginVersion (pluginName: string, newVersion: string) {
-    return this.setPluginField(pluginName, 'version', newVersion)
-  }
+  updateQuery (query: string, replacements: { [id: string]: string | number } = {}) {
+    const seq = this.getSequelize()
+    const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE, replacements }
 
-  setPluginLatestVersion (pluginName: string, newVersion: string) {
-    return this.setPluginField(pluginName, 'latestVersion', newVersion)
+    return seq.query(query, options)
   }
 
   // ---------------------------------------------------------------------------
 
   async getPlaylistInfohash (playlistId: number) {
-    const result = await this.selectQuery('SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = ' + playlistId)
+    const query = 'SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = :playlistId'
+
+    const result = await this.selectQuery<{ p2pMediaLoaderInfohashes: string }>(query, { playlistId })
     if (!result || result.length === 0) return []
 
     return result[0].p2pMediaLoaderInfohashes
@@ -116,19 +102,14 @@ export class SQLCommand extends AbstractCommand {
   // ---------------------------------------------------------------------------
 
   setActorFollowScores (newScore: number) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE "actorFollow" SET "score" = ${newScore}`, options)
+    return this.updateQuery(`UPDATE "actorFollow" SET "score" = :newScore`, { newScore })
   }
 
   setTokenField (accessToken: string, field: string, value: string) {
-    const seq = this.getSequelize()
-
-    const options = { type: QueryTypes.UPDATE }
-
-    return seq.query(`UPDATE "oAuthToken" SET "${field}" = '${value}' WHERE "accessToken" = '${accessToken}'`, options)
+    return this.updateQuery(
+      `UPDATE "oAuthToken" SET ${this.escapeColumnName(field)} = :value WHERE "accessToken" = :accessToken`,
+      { value, accessToken }
+    )
   }
 
   async cleanup () {
@@ -157,4 +138,9 @@ export class SQLCommand extends AbstractCommand {
     return this.sequelize
   }
 
+  private escapeColumnName (columnName: string) {
+    return this.getSequelize().escape(columnName)
+      .replace(/^'/, '"')
+      .replace(/'$/, '"')
+  }
 }
index dc9cf4e015a2bcea704ceec00896fcd98bfecda6..cb0e1a5fbd920f3646f9f4c9d1a3b3022977e4b4 100644 (file)
@@ -199,7 +199,7 @@ function buildRequest (req: request.Test, options: CommonRequestParams) {
   return req.expect((res) => {
     if (options.expectedStatus && res.status !== options.expectedStatus) {
       throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` +
-        `\nThe server responded this error: "${res.body?.error ?? res.text}".\n` +
+        `\nThe server responded: "${res.body?.error ?? res.text}".\n` +
         'You may take a closer look at the logs. To see how to do so, check out this page: ' +
         'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs')
     }
index bf53b8080a7481ae97bc62d22d6f4559501aa669..5cf1d5879a28471107432cbeb259ee9ed9d5fb7b 100644 (file)
@@ -2,8 +2,6 @@
 
 :warning: **Warning**: dependencies guide is maintained by the community. Some parts may be outdated! :warning:
 
-Follow the below guides, and check their versions match [required external dependencies versions](https://github.com/Chocobozzz/PeerTube/blob/master/engines.yaml).
-
 Main dependencies version supported by PeerTube:
 
  * `node` >=14.x
index 267863a4d38c34b3ab058be16f2fad64df155b0e..b6990f3e340704cab1e58448554704ea9ec8ea23 100644 (file)
@@ -120,7 +120,7 @@ See the production guide ["What now" section](https://docs.joinpeertube.org/inst
 
 ## Upgrade
 
-**Important:** Before upgrading, check you have all the `storage` fields in your [production.yaml file](https://github.com/Chocobozzz/PeerTube/blob/master/support/docker/production/config/production.yaml).
+**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
 
 Pull the latest images:
 
index a1131ced542f4bf3e64a94a473f455c3b8a5debf..9ddab3ece7c89da363e03ea2efeb273de3610735 100644 (file)
@@ -433,7 +433,27 @@ function register (...) {
       username: 'user'
       email: 'user@example.com'
       role: 2
-      displayName: 'User display name'
+      displayName: 'User display name',
+
+      // Custom admin flags (bypass video auto moderation etc.)
+      // https://github.com/Chocobozzz/PeerTube/blob/develop/shared/models/users/user-flag.model.ts
+      // PeerTube >= 5.1
+      adminFlags: 0,
+      // Quota in bytes
+      // PeerTube >= 5.1
+      videoQuota: 1024 * 1024 * 1024, // 1GB
+      // PeerTube >= 5.1
+      videoQuotaDaily: -1, // Unlimited
+
+      // Update the user profile if it already exists
+      // Default behaviour is no update
+      // Introduced in PeerTube >= 5.1
+      userUpdater: ({ fieldName, currentValue, newValue }) => {
+        // Always use new value except for videoQuotaDaily field
+        if (fieldName === 'videoQuotaDaily') return currentValue
+
+        return newValue
+      }
     })
   })
 
index dd57e912088228c1169abee58559b66d9a248a2a..9a84f19a388abe120b32d07b3e117b692abb6c1d 100644 (file)
@@ -177,16 +177,17 @@ $ sudo vim /etc/letsencrypt/renewal/your-domain.com.conf
 
 If you plan to have many concurrent viewers on your PeerTube instance, consider increasing `worker_connections` value: https://nginx.org/en/docs/ngx_core_module.html#worker_connections.
 
-**FreeBSD**
+<details>
+<summary><strong>If using FreeBSD</strong></summary>
+
 On FreeBSD you can use [Dehydrated](https://dehydrated.io/) `security/dehydrated` for [Let's Encrypt](https://letsencrypt.org/)
 
 ```bash
 $ sudo pkg install dehydrated
 ```
+</details>
 
-### :alembic: TCP/IP Tuning
-
-**On Linux**
+### :alembic: Linux TCP/IP Tuning
 
 ```bash
 $ sudo cp /var/www/peertube/peertube-latest/support/sysctl.d/30-peertube-tcp.conf /etc/sysctl.d/
@@ -231,7 +232,9 @@ $ sudo systemctl start peertube
 $ sudo journalctl -feu peertube
 ```
 
-**FreeBSD**
+<details>
+<summary><strong>If using FreeBSD</strong></summary>
+
 On FreeBSD, copy the startup script and update rc.conf:
 
 ```bash
@@ -244,8 +247,10 @@ Run:
 ```bash
 $ sudo service peertube start
 ```
+</details>
 
-### :bricks: OpenRC
+<details>
+<summary><strong>If using OpenRC</strong></summary>
 
 If your OS uses OpenRC, copy the service script:
 
@@ -265,6 +270,7 @@ Run and print last logs:
 $ sudo /etc/init.d/peertube start
 $ tail -f /var/log/peertube/peertube.log
 ```
+</details>
 
 ### :technologist: Administrator
 
@@ -291,16 +297,15 @@ Now your instance is up you can:
 
 **Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
 
-#### Auto
-
-The password it asks is PeerTube's database user password.
+Run the upgrade script (the password it asks is PeerTube's database user password):
 
 ```bash
 $ cd /var/www/peertube/peertube-latest/scripts && sudo -H -u peertube ./upgrade.sh
 $ sudo systemctl restart peertube # Or use your OS command to restart PeerTube if you don't use systemd
 ```
 
-#### Manually
+<details>
+<summary><strong>Prefer manual upgrade?</strong></summary>
 
 Make a SQL backup
 
@@ -346,17 +351,18 @@ $ cd /var/www/peertube && \
     sudo unlink ./peertube-latest && \
     sudo -u peertube ln -s versions/peertube-${VERSION} ./peertube-latest
 ```
+</details>
 
-### Configuration
+### Update PeerTube configuration
 
-You can check for configuration changes, and report them in your `config/production.yaml` file:
+Check for configuration changes, and report them in your `config/production.yaml` file:
 
 ```bash
 $ cd /var/www/peertube/versions
 $ diff -u "$(ls --sort=t | head -2 | tail -1)/config/production.yaml.example" "$(ls --sort=t | head -1)/config/production.yaml.example"
 ```
 
-### nginx
+### Update nginx configuration
 
 Check changes in nginx configuration:
 
@@ -365,7 +371,7 @@ $ cd /var/www/peertube/versions
 $ diff -u "$(ls --sort=t | head -2 | tail -1)/support/nginx/peertube" "$(ls --sort=t | head -1)/support/nginx/peertube"
 ```
 
-### systemd
+### Update systemd service
 
 Check changes in systemd configuration:
 
index 993acf81d43f03ad3efd868f2c47aacd1f7dc14d..8bcd944e3cb50472021196e6b6e178947031871c 100644 (file)
@@ -8,7 +8,6 @@
       "@shared/*": [ "shared/*" ]
     },
     "typeRoots": [
-      "server/typings",
       "node_modules/@types"
     ]
   },
@@ -17,5 +16,5 @@
     { "path": "./server" },
     { "path": "./scripts" }
   ],
-  "files": [ "server.ts", "server/types/express.d.ts" ]
+  "files": [ "server.ts", "server/types/express.d.ts", "server/types/lib.d.ts" ]
 }
index 4093a87fd149e8fc865b330bc0d1928f6ba0fb14..d9541b4d8533c8465efb6c3456b1f0abe3a8eab3 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -9099,7 +9099,7 @@ typedarray@^0.0.6:
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
 
-typescript@^4.0.5:
+typescript@~4.8:
   version "4.8.4"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
   integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==