aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/test.yml1
-rw-r--r--client/package.json4
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html6
-rw-r--r--client/src/app/+login/login.component.ts5
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts40
-rw-r--r--client/src/app/+videos/video-list/videos-list-common-page.component.ts3
-rw-r--r--client/src/app/core/auth/auth.service.ts45
-rw-r--r--client/src/app/core/rest/rest-extractor.service.ts6
-rw-r--r--client/src/app/menu/menu.component.ts8
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts2
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.ts62
-rw-r--r--client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts4
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist.service.ts16
-rw-r--r--client/src/assets/player/peertube-player-manager.ts7
-rw-r--r--client/src/assets/player/shared/control-bar/index.ts1
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-live-display.ts93
-rw-r--r--client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts76
-rw-r--r--client/src/assets/player/shared/manager-options/control-bar-options-builder.ts25
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts4
-rw-r--r--client/src/assets/player/shared/stats/stats-card.ts4
-rw-r--r--client/src/assets/player/types/manager-options.ts2
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts3
-rw-r--r--client/src/root-helpers/logger.ts8
-rw-r--r--client/src/root-helpers/plugins-manager.ts11
-rw-r--r--client/src/sass/player/control-bar.scss21
-rw-r--r--client/src/sass/primeng-custom.scss1
-rw-r--r--client/src/standalone/videos/shared/player-manager-options.ts4
-rw-r--r--client/yarn.lock18
-rw-r--r--config/default.yaml5
-rw-r--r--config/production.yaml.example5
-rw-r--r--package.json2
-rwxr-xr-xscripts/i18n/create-custom-files.ts1
-rw-r--r--server.ts5
-rw-r--r--server/controllers/activitypub/client.ts2
-rw-r--r--server/controllers/api/videos/comment.ts7
-rw-r--r--server/controllers/api/videos/token.ts2
-rw-r--r--server/controllers/feeds.ts4
-rw-r--r--server/controllers/tracker.ts34
-rw-r--r--server/helpers/custom-validators/misc.ts8
-rw-r--r--server/helpers/custom-validators/video-captions.ts9
-rw-r--r--server/helpers/custom-validators/video-imports.ts9
-rw-r--r--server/helpers/decache.ts2
-rw-r--r--server/helpers/memoize.ts12
-rw-r--r--server/helpers/youtube-dl/youtube-dl-cli.ts12
-rw-r--r--server/initializers/checker-after-init.ts3
-rw-r--r--server/initializers/checker-before-init.ts1
-rw-r--r--server/initializers/config.ts6
-rw-r--r--server/initializers/constants.ts11
-rw-r--r--server/initializers/installer.ts6
-rw-r--r--server/lib/auth/external-auth.ts72
-rw-r--r--server/lib/auth/oauth-model.ts75
-rw-r--r--server/lib/auth/oauth.ts14
-rw-r--r--server/lib/auth/tokens-cache.ts8
-rw-r--r--server/lib/job-queue/job-queue.ts2
-rw-r--r--server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts51
-rw-r--r--server/lib/opentelemetry/metric-helpers/index.ts1
-rw-r--r--server/lib/opentelemetry/metrics.ts6
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts6
-rw-r--r--server/lib/redis.ts15
-rw-r--r--server/lib/sync-channel.ts2
-rw-r--r--server/lib/video-comment.ts33
-rw-r--r--server/lib/video-tokens-manager.ts22
-rw-r--r--server/middlewares/sort.ts23
-rw-r--r--server/middlewares/validators/shared/videos.ts12
-rw-r--r--server/models/abuse/abuse-message.ts2
-rw-r--r--server/models/abuse/abuse.ts4
-rw-r--r--server/models/abuse/sql/abuse-query-builder.ts (renamed from server/models/abuse/abuse-query-builder.ts)4
-rw-r--r--server/models/account/account-blocklist.ts2
-rw-r--r--server/models/account/account-video-rate.ts2
-rw-r--r--server/models/account/account.ts16
-rw-r--r--server/models/actor/actor-follow.ts16
-rw-r--r--server/models/actor/actor-image.ts14
-rw-r--r--server/models/actor/actor.ts27
-rw-r--r--server/models/actor/sql/instance-list-followers-query-builder.ts2
-rw-r--r--server/models/actor/sql/instance-list-following-query-builder.ts2
-rw-r--r--server/models/actor/sql/shared/actor-follow-table-attributes.ts65
-rw-r--r--server/models/actor/sql/shared/instance-list-follows-query-builder.ts2
-rw-r--r--server/models/redundancy/video-redundancy.ts2
-rw-r--r--server/models/server/plugin.ts2
-rw-r--r--server/models/server/server-blocklist.ts2
-rw-r--r--server/models/server/server.ts14
-rw-r--r--server/models/shared/index.ts4
-rw-r--r--server/models/shared/model-builder.ts27
-rw-r--r--server/models/shared/model-cache.ts (renamed from server/models/model-cache.ts)0
-rw-r--r--server/models/shared/query.ts75
-rw-r--r--server/models/shared/sequelize-helpers.ts39
-rw-r--r--server/models/shared/sort.ts160
-rw-r--r--server/models/shared/sql.ts68
-rw-r--r--server/models/shared/update.ts14
-rw-r--r--server/models/user/sql/user-notitication-list-query-builder.ts2
-rw-r--r--server/models/user/user-notification-setting.ts2
-rw-r--r--server/models/user/user-notification.ts2
-rw-r--r--server/models/user/user.ts4
-rw-r--r--server/models/utils.ts317
-rw-r--r--server/models/video/sql/comment/video-comment-list-query-builder.ts385
-rw-r--r--server/models/video/sql/comment/video-comment-table-attributes.ts43
-rw-r--r--server/models/video/sql/video/shared/abstract-video-query-builder.ts2
-rw-r--r--server/models/video/sql/video/videos-id-list-query-builder.ts7
-rw-r--r--server/models/video/tag.ts2
-rw-r--r--server/models/video/video-blacklist.ts6
-rw-r--r--server/models/video/video-caption.ts2
-rw-r--r--server/models/video/video-change-ownership.ts2
-rw-r--r--server/models/video/video-channel-sync.ts2
-rw-r--r--server/models/video/video-channel.ts12
-rw-r--r--server/models/video/video-comment.ts458
-rw-r--r--server/models/video/video-file.ts13
-rw-r--r--server/models/video/video-import.ts2
-rw-r--r--server/models/video/video-playlist-element.ts2
-rw-r--r--server/models/video/video-playlist.ts12
-rw-r--r--server/models/video/video-share.ts2
-rw-r--r--server/models/video/video-streaming-playlist.ts7
-rw-r--r--server/models/video/video.ts7
-rw-r--r--server/tests/api/activitypub/cleaner.ts8
-rw-r--r--server/tests/api/check-params/redundancy.ts2
-rw-r--r--server/tests/api/live/live-fast-restream.ts14
-rw-r--r--server/tests/api/notifications/moderation-notifications.ts28
-rw-r--r--server/tests/api/object-storage/video-static-file-privacy.ts4
-rw-r--r--server/tests/api/users/index.ts1
-rw-r--r--server/tests/api/users/oauth.ts192
-rw-r--r--server/tests/api/users/users.ts184
-rw-r--r--server/tests/api/videos/video-channel-syncs.ts3
-rw-r--r--server/tests/api/videos/video-comments.ts26
-rw-r--r--server/tests/external-plugins/auto-block-videos.ts2
-rw-r--r--server/tests/external-plugins/auto-mute.ts2
-rw-r--r--server/tests/feeds/feeds.ts8
-rw-r--r--server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js12
-rw-r--r--server/tests/fixtures/peertube-plugin-test-four/main.js6
-rw-r--r--server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js13
-rw-r--r--server/tests/fixtures/peertube-plugin-test/main.js5
-rw-r--r--server/tests/plugins/external-auth.ts42
-rw-r--r--server/tests/plugins/filter-hooks.ts47
-rw-r--r--server/tests/plugins/id-and-pass-auth.ts34
-rw-r--r--server/tests/plugins/plugin-helpers.ts12
-rw-r--r--server/types/express.d.ts6
-rw-r--r--server/types/lib.d.ts12
-rw-r--r--server/types/plugins/register-server-auth.model.ts21
-rw-r--r--server/types/plugins/register-server-option.model.ts3
-rw-r--r--shared/core-utils/plugins/hooks.ts8
-rw-r--r--shared/server-commands/miscs/sql-command.ts102
-rw-r--r--shared/server-commands/requests/requests.ts2
-rw-r--r--support/doc/dependencies.md2
-rw-r--r--support/doc/docker.md2
-rw-r--r--support/doc/plugins/guide.md22
-rw-r--r--support/doc/production.md34
-rw-r--r--tsconfig.json3
-rw-r--r--yarn.lock2
146 files changed, 2282 insertions, 1408 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 65e1acec6..678b0674b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -48,6 +48,7 @@ jobs:
48 ENABLE_OBJECT_STORAGE_TESTS: true 48 ENABLE_OBJECT_STORAGE_TESTS: true
49 OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }} 49 OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }}
50 OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }} 50 OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }}
51 YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51 52
52 steps: 53 steps:
53 - uses: actions/checkout@v3 54 - uses: actions/checkout@v3
diff --git a/client/package.json b/client/package.json
index 115a4a199..6f88d4fb9 100644
--- a/client/package.json
+++ b/client/package.json
@@ -52,8 +52,8 @@
52 "@ngx-loading-bar/core": "^6.0.0", 52 "@ngx-loading-bar/core": "^6.0.0",
53 "@ngx-loading-bar/http-client": "^6.0.0", 53 "@ngx-loading-bar/http-client": "^6.0.0",
54 "@ngx-loading-bar/router": "^6.0.0", 54 "@ngx-loading-bar/router": "^6.0.0",
55 "@peertube/p2p-media-loader-core": "^1.0.13", 55 "@peertube/p2p-media-loader-core": "^1.0.14",
56 "@peertube/p2p-media-loader-hlsjs": "^1.0.13", 56 "@peertube/p2p-media-loader-hlsjs": "^1.0.14",
57 "@peertube/videojs-contextmenu": "^5.5.0", 57 "@peertube/videojs-contextmenu": "^5.5.0",
58 "@peertube/xliffmerge": "^2.0.3", 58 "@peertube/xliffmerge": "^2.0.3",
59 "@popperjs/core": "^2.11.5", 59 "@popperjs/core": "^2.11.5",
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
index 43f1438e0..174f5d29c 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
@@ -44,9 +44,13 @@
44 44
45 <div class="peertube-select-container"> 45 <div class="peertube-select-container">
46 <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control"> 46 <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
47 <option i18n value="publishedAt">Recently added videos</option>
48 <option i18n value="originallyPublishedAt">Original publication date</option>
49 <option i18n value="name">Name</option>
47 <option i18n value="hot">Hot videos</option> 50 <option i18n value="hot">Hot videos</option>
48 <option i18n value="most-viewed">Most viewed videos</option> 51 <option i18n value="most-viewed">Recent views</option>
49 <option i18n value="most-liked">Most liked videos</option> 52 <option i18n value="most-liked">Most liked videos</option>
53 <option i18n value="views">Global views</option>
50 </select> 54 </select>
51 </div> 55 </div>
52 56
diff --git a/client/src/app/+login/login.component.ts b/client/src/app/+login/login.component.ts
index c1705807f..5f6aa842e 100644
--- a/client/src/app/+login/login.component.ts
+++ b/client/src/app/+login/login.component.ts
@@ -1,3 +1,4 @@
1import { environment } from 'src/environments/environment'
1import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' 2import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' 4import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
@@ -7,7 +8,7 @@ import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-valid
7import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms' 8import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
8import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' 9import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
9import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 10import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
10import { PluginsManager } from '@root-helpers/plugins-manager' 11import { getExternalAuthHref } from '@shared/core-utils'
11import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models' 12import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
12 13
13@Component({ 14@Component({
@@ -119,7 +120,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
119 } 120 }
120 121
121 getAuthHref (auth: RegisteredExternalAuthConfig) { 122 getAuthHref (auth: RegisteredExternalAuthConfig) {
122 return PluginsManager.getExternalAuthHref(auth) 123 return getExternalAuthHref(environment.apiUrl, auth)
123 } 124 }
124 125
125 login () { 126 login () {
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index 94853423b..84548de97 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -133,8 +133,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
133 this.loadRouteParams() 133 this.loadRouteParams()
134 this.loadRouteQuery() 134 this.loadRouteQuery()
135 135
136 this.initHotkeys()
137
138 this.theaterEnabled = getStoredTheater() 136 this.theaterEnabled = getStoredTheater()
139 137
140 this.hooks.runAction('action:video-watch.init', 'video-watch') 138 this.hooks.runAction('action:video-watch.init', 'video-watch')
@@ -295,6 +293,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
295 subtitle: queryParams.subtitle, 293 subtitle: queryParams.subtitle,
296 294
297 playerMode: queryParams.mode, 295 playerMode: queryParams.mode,
296 playbackRate: queryParams.playbackRate,
298 peertubeLink: false 297 peertubeLink: false
299 } 298 }
300 299
@@ -406,6 +405,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
406 if (res === false) return this.location.back() 405 if (res === false) return this.location.back()
407 } 406 }
408 407
408 this.buildHotkeysHelp(video)
409
409 this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay }) 410 this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay })
410 .catch(err => logger.error('Cannot build the player', err)) 411 .catch(err => logger.error('Cannot build the player', err))
411 412
@@ -657,6 +658,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
657 muted: urlOptions.muted, 658 muted: urlOptions.muted,
658 loop: urlOptions.loop, 659 loop: urlOptions.loop,
659 subtitle: urlOptions.subtitle, 660 subtitle: urlOptions.subtitle,
661 playbackRate: urlOptions.playbackRate,
660 662
661 peertubeLink: urlOptions.peertubeLink, 663 peertubeLink: urlOptions.peertubeLink,
662 664
@@ -785,33 +787,43 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
785 this.video.viewers = newViewers 787 this.video.viewers = newViewers
786 } 788 }
787 789
788 private initHotkeys () { 790 private buildHotkeysHelp (video: Video) {
791 if (this.hotkeys.length !== 0) {
792 this.hotkeysService.remove(this.hotkeys)
793 }
794
789 this.hotkeys = [ 795 this.hotkeys = [
790 // These hotkeys are managed by the player 796 // These hotkeys are managed by the player
791 new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen`), 797 new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen`),
792 new Hotkey('space', e => e, undefined, $localize`Play/Pause the video`), 798 new Hotkey('space', e => e, undefined, $localize`Play/Pause the video`),
793 new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video`), 799 new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video`),
794 800
795 new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
796
797 new Hotkey('up', e => e, undefined, $localize`Increase the volume`), 801 new Hotkey('up', e => e, undefined, $localize`Increase the volume`),
798 new Hotkey('down', e => e, undefined, $localize`Decrease the volume`), 802 new Hotkey('down', e => e, undefined, $localize`Decrease the volume`),
799 803
800 new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
801 new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
802
803 new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
804 new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
805
806 new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
807 new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`),
808
809 new Hotkey('t', e => { 804 new Hotkey('t', e => {
810 this.theaterEnabled = !this.theaterEnabled 805 this.theaterEnabled = !this.theaterEnabled
811 return false 806 return false
812 }, undefined, $localize`Toggle theater mode`) 807 }, undefined, $localize`Toggle theater mode`)
813 ] 808 ]
814 809
810 if (!video.isLive) {
811 this.hotkeys = this.hotkeys.concat([
812 // These hotkeys are also managed by the player but only for VOD
813
814 new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
815
816 new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
817 new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
818
819 new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
820 new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
821
822 new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
823 new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`)
824 ])
825 }
826
815 if (this.isUserLoggedIn()) { 827 if (this.isUserLoggedIn()) {
816 this.hotkeys = this.hotkeys.concat([ 828 this.hotkeys = this.hotkeys.concat([
817 new Hotkey('shift+s', () => { 829 new Hotkey('shift+s', () => {
diff --git a/client/src/app/+videos/video-list/videos-list-common-page.component.ts b/client/src/app/+videos/video-list/videos-list-common-page.component.ts
index c8fa8ef30..bafe30fd7 100644
--- a/client/src/app/+videos/video-list/videos-list-common-page.component.ts
+++ b/client/src/app/+videos/video-list/videos-list-common-page.component.ts
@@ -177,6 +177,9 @@ export class VideosListCommonPageComponent implements OnInit, OnDestroy, Disable
177 case 'best': 177 case 'best':
178 return '-hot' 178 return '-hot'
179 179
180 case 'name':
181 return 'name'
182
180 default: 183 default:
181 return '-' + algorithm as VideoSortField 184 return '-' + algorithm as VideoSortField
182 } 185 }
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts
index 4de28e51e..ed7eabb76 100644
--- a/client/src/app/core/auth/auth.service.ts
+++ b/client/src/app/core/auth/auth.service.ts
@@ -5,10 +5,11 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular
5import { Injectable } from '@angular/core' 5import { Injectable } from '@angular/core'
6import { Router } from '@angular/router' 6import { Router } from '@angular/router'
7import { Notifier } from '@app/core/notification/notifier.service' 7import { Notifier } from '@app/core/notification/notifier.service'
8import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index' 8import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage, PluginsManager } from '@root-helpers/index'
9import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models' 9import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models'
10import { environment } from '../../../environments/environment' 10import { environment } from '../../../environments/environment'
11import { RestExtractor } from '../rest/rest-extractor.service' 11import { RestExtractor } from '../rest/rest-extractor.service'
12import { ServerService } from '../server'
12import { AuthStatus } from './auth-status.model' 13import { AuthStatus } from './auth-status.model'
13import { AuthUser } from './auth-user.model' 14import { AuthUser } from './auth-user.model'
14 15
@@ -44,6 +45,7 @@ export class AuthService {
44 private refreshingTokenObservable: Observable<any> 45 private refreshingTokenObservable: Observable<any>
45 46
46 constructor ( 47 constructor (
48 private serverService: ServerService,
47 private http: HttpClient, 49 private http: HttpClient,
48 private notifier: Notifier, 50 private notifier: Notifier,
49 private hotkeysService: HotkeysService, 51 private hotkeysService: HotkeysService,
@@ -213,25 +215,28 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
213 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') 215 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
214 216
215 this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers }) 217 this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers })
216 .pipe( 218 .pipe(
217 map(res => this.handleRefreshToken(res)), 219 map(res => this.handleRefreshToken(res)),
218 tap(() => { 220 tap(() => {
219 this.refreshingTokenObservable = null 221 this.refreshingTokenObservable = null
220 }), 222 }),
221 catchError(err => { 223 catchError(err => {
222 this.refreshingTokenObservable = null 224 this.refreshingTokenObservable = null
223 225
224 logger.error(err) 226 logger.error(err)
225 logger.info('Cannot refresh token -> logout...') 227 logger.info('Cannot refresh token -> logout...')
226 this.logout() 228 this.logout()
227 this.router.navigate([ '/login' ]) 229
228 230 const externalLoginUrl = PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverService.getHTMLConfig())
229 return observableThrowError(() => ({ 231 if (externalLoginUrl) window.location.href = externalLoginUrl
230 error: $localize`You need to reconnect.` 232 else this.router.navigate([ '/login' ])
231 })) 233
232 }), 234 return observableThrowError(() => ({
233 share() 235 error: $localize`You need to reconnect.`
234 ) 236 }))
237 }),
238 share()
239 )
235 240
236 return this.refreshingTokenObservable 241 return this.refreshingTokenObservable
237 } 242 }
diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts
index de3f2bfff..daed7f178 100644
--- a/client/src/app/core/rest/rest-extractor.service.ts
+++ b/client/src/app/core/rest/rest-extractor.service.ts
@@ -87,7 +87,11 @@ export class RestExtractor {
87 87
88 if (err.status !== undefined) { 88 if (err.status !== undefined) {
89 const errorMessage = this.buildServerErrorMessage(err) 89 const errorMessage = this.buildServerErrorMessage(err)
90 logger.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) 90
91 const message = `Backend returned code ${err.status}, errorMessage is: ${errorMessage}`
92
93 if (err.status === HttpStatusCode.NOT_FOUND_404) logger.clientError(message)
94 else logger.error(message)
91 95
92 return errorMessage 96 return errorMessage
93 } 97 }
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts
index 63f01df92..568cb98bb 100644
--- a/client/src/app/menu/menu.component.ts
+++ b/client/src/app/menu/menu.component.ts
@@ -1,6 +1,7 @@
1import { HotkeysService } from 'angular2-hotkeys' 1import { HotkeysService } from 'angular2-hotkeys'
2import * as debug from 'debug' 2import * as debug from 'debug'
3import { switchMap } from 'rxjs/operators' 3import { switchMap } from 'rxjs/operators'
4import { environment } from 'src/environments/environment'
4import { ViewportScroller } from '@angular/common' 5import { ViewportScroller } from '@angular/common'
5import { Component, OnInit, ViewChild } from '@angular/core' 6import { Component, OnInit, ViewChild } from '@angular/core'
6import { Router } from '@angular/router' 7import { Router } from '@angular/router'
@@ -131,12 +132,7 @@ export class MenuComponent implements OnInit {
131 } 132 }
132 133
133 getExternalLoginHref () { 134 getExternalLoginHref () {
134 if (!this.serverConfig || this.serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined 135 return PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverConfig)
135
136 const externalAuths = this.serverConfig.plugin.registeredExternalAuths
137 if (externalAuths.length !== 1) return undefined
138
139 return PluginsManager.getExternalAuthHref(externalAuths[0])
140 } 136 }
141 137
142 isRegistrationAllowed () { 138 isRegistrationAllowed () {
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 85c63c173..706227e66 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -314,6 +314,6 @@ export class VideoMiniatureComponent implements OnInit {
314 this.cd.markForCheck() 314 this.cd.markForCheck()
315 }) 315 })
316 316
317 this.videoPlaylistService.runPlaylistCheck(this.video.id) 317 this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
318 } 318 }
319} 319}
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
index d5cdd958e..a423377de 100644
--- a/client/src/app/shared/shared-video-miniature/videos-list.component.ts
+++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
@@ -1,6 +1,6 @@
1import * as debug from 'debug' 1import * as debug from 'debug'
2import { fromEvent, Observable, Subject, Subscription } from 'rxjs' 2import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
3import { debounceTime, switchMap } from 'rxjs/operators' 3import { concatMap, debounceTime, map, switchMap } from 'rxjs/operators'
4import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core' 4import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
5import { ActivatedRoute } from '@angular/router' 5import { ActivatedRoute } from '@angular/router'
6import { 6import {
@@ -111,6 +111,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
111 111
112 private lastQueryLength: number 112 private lastQueryLength: number
113 113
114 private videoRequests = new Subject<{ reset: boolean, obs: Observable<ResultList<Video>> }>()
115
114 constructor ( 116 constructor (
115 private notifier: Notifier, 117 private notifier: Notifier,
116 private authService: AuthService, 118 private authService: AuthService,
@@ -124,6 +126,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
124 } 126 }
125 127
126 ngOnInit () { 128 ngOnInit () {
129 this.subscribeToVideoRequests()
130
127 const hiddenFilters = this.hideScopeFilter 131 const hiddenFilters = this.hideScopeFilter
128 ? [ 'scope' ] 132 ? [ 'scope' ]
129 : [] 133 : []
@@ -228,30 +232,12 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
228 } 232 }
229 233
230 loadMoreVideos (reset = false) { 234 loadMoreVideos (reset = false) {
231 if (reset) this.hasDoneFirstQuery = false 235 if (reset) {
232 236 this.hasDoneFirstQuery = false
233 this.getVideosObservableFunction(this.pagination, this.filters) 237 this.videos = []
234 .subscribe({ 238 }
235 next: ({ data }) => {
236 this.hasDoneFirstQuery = true
237 this.lastQueryLength = data.length
238
239 if (reset) this.videos = []
240 this.videos = this.videos.concat(data)
241
242 if (this.groupByDate) this.buildGroupedDateLabels()
243
244 this.onDataSubject.next(data)
245 this.videosLoaded.emit(this.videos)
246 },
247
248 error: err => {
249 const message = $localize`Cannot load more videos. Try again later.`
250 239
251 logger.error(message, err) 240 this.videoRequests.next({ reset, obs: this.getVideosObservableFunction(this.pagination, this.filters) })
252 this.notifier.error(message)
253 }
254 })
255 } 241 }
256 242
257 reloadVideos () { 243 reloadVideos () {
@@ -423,4 +409,32 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
423 this.onFiltersChanged(true) 409 this.onFiltersChanged(true)
424 }) 410 })
425 } 411 }
412
413 private subscribeToVideoRequests () {
414 this.videoRequests
415 .pipe(concatMap(({ reset, obs }) => obs.pipe(map(({ data }) => ({ data, reset })))))
416 .subscribe({
417 next: ({ data, reset }) => {
418 console.log(data[0].name)
419
420 this.hasDoneFirstQuery = true
421 this.lastQueryLength = data.length
422
423 if (reset) this.videos = []
424 this.videos = this.videos.concat(data)
425
426 if (this.groupByDate) this.buildGroupedDateLabels()
427
428 this.onDataSubject.next(data)
429 this.videosLoaded.emit(this.videos)
430 },
431
432 error: err => {
433 const message = $localize`Cannot load more videos. Try again later.`
434
435 logger.error(message, err)
436 this.notifier.error(message)
437 }
438 })
439 }
426} 440}
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
index 2fc39fc75..f802416a4 100644
--- a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
@@ -81,7 +81,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
81 .subscribe(result => { 81 .subscribe(result => {
82 this.playlistsData = result.data 82 this.playlistsData = result.data
83 83
84 this.videoPlaylistService.runPlaylistCheck(this.video.id) 84 this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
85 }) 85 })
86 86
87 this.videoPlaylistSearchChanged 87 this.videoPlaylistSearchChanged
@@ -129,7 +129,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
129 .subscribe(playlistsResult => { 129 .subscribe(playlistsResult => {
130 this.playlistsData = playlistsResult.data 130 this.playlistsData = playlistsResult.data
131 131
132 this.videoPlaylistService.runPlaylistCheck(this.video.id) 132 this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
133 }) 133 })
134 } 134 }
135 135
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
index 330a51f91..bc9fb0d74 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
@@ -206,7 +206,15 @@ export class VideoPlaylistService {
206 stopTimestamp: body.stopTimestamp 206 stopTimestamp: body.stopTimestamp
207 }) 207 })
208 208
209 this.runPlaylistCheck(body.videoId) 209 this.runVideoExistsInPlaylistCheck(body.videoId)
210
211 if (this.myAccountPlaylistCache) {
212 const playlist = this.myAccountPlaylistCache.data.find(p => p.id === playlistId)
213 if (!playlist) return
214
215 const otherPlaylists = this.myAccountPlaylistCache.data.filter(p => p !== playlist)
216 this.myAccountPlaylistCache.data = [ playlist, ...otherPlaylists ]
217 }
210 }), 218 }),
211 catchError(err => this.restExtractor.handleError(err)) 219 catchError(err => this.restExtractor.handleError(err))
212 ) 220 )
@@ -225,7 +233,7 @@ export class VideoPlaylistService {
225 elem.stopTimestamp = body.stopTimestamp 233 elem.stopTimestamp = body.stopTimestamp
226 } 234 }
227 235
228 this.runPlaylistCheck(videoId) 236 this.runVideoExistsInPlaylistCheck(videoId)
229 }), 237 }),
230 catchError(err => this.restExtractor.handleError(err)) 238 catchError(err => this.restExtractor.handleError(err))
231 ) 239 )
@@ -242,7 +250,7 @@ export class VideoPlaylistService {
242 .filter(e => e.playlistElementId !== playlistElementId) 250 .filter(e => e.playlistElementId !== playlistElementId)
243 } 251 }
244 252
245 this.runPlaylistCheck(videoId) 253 this.runVideoExistsInPlaylistCheck(videoId)
246 }), 254 }),
247 catchError(err => this.restExtractor.handleError(err)) 255 catchError(err => this.restExtractor.handleError(err))
248 ) 256 )
@@ -296,7 +304,7 @@ export class VideoPlaylistService {
296 return obs 304 return obs
297 } 305 }
298 306
299 runPlaylistCheck (videoId: number) { 307 runVideoExistsInPlaylistCheck (videoId: number) {
300 debugLogger('Running playlist check.') 308 debugLogger('Running playlist check.')
301 309
302 if (this.videoExistsCache[videoId]) { 310 if (this.videoExistsCache[videoId]) {
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index 56310c4e9..2781850b9 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -11,6 +11,7 @@ import './shared/control-bar/p2p-info-button'
11import './shared/control-bar/peertube-link-button' 11import './shared/control-bar/peertube-link-button'
12import './shared/control-bar/peertube-load-progress-bar' 12import './shared/control-bar/peertube-load-progress-bar'
13import './shared/control-bar/theater-button' 13import './shared/control-bar/theater-button'
14import './shared/control-bar/peertube-live-display'
14import './shared/settings/resolution-menu-button' 15import './shared/settings/resolution-menu-button'
15import './shared/settings/resolution-menu-item' 16import './shared/settings/resolution-menu-item'
16import './shared/settings/settings-dialog' 17import './shared/settings/settings-dialog'
@@ -96,6 +97,10 @@ export class PeertubePlayerManager {
96 videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) { 97 videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
97 const player = this 98 const player = this
98 99
100 if (!isNaN(+options.common.playbackRate)) {
101 player.playbackRate(+options.common.playbackRate)
102 }
103
99 let alreadyFallback = false 104 let alreadyFallback = false
100 105
101 const handleError = () => { 106 const handleError = () => {
@@ -118,7 +123,7 @@ export class PeertubePlayerManager {
118 self.addContextMenu(videojsOptionsBuilder, player, options.common) 123 self.addContextMenu(videojsOptionsBuilder, player, options.common)
119 124
120 if (isMobile()) player.peertubeMobile() 125 if (isMobile()) player.peertubeMobile()
121 if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin() 126 if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive })
122 if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden') 127 if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden')
123 128
124 player.bezels() 129 player.bezels()
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts
index db5b8938d..e71e90713 100644
--- a/client/src/assets/player/shared/control-bar/index.ts
+++ b/client/src/assets/player/shared/control-bar/index.ts
@@ -1,5 +1,6 @@
1export * from './next-previous-video-button' 1export * from './next-previous-video-button'
2export * from './p2p-info-button' 2export * from './p2p-info-button'
3export * from './peertube-link-button' 3export * from './peertube-link-button'
4export * from './peertube-live-display'
4export * from './peertube-load-progress-bar' 5export * from './peertube-load-progress-bar'
5export * from './theater-button' 6export * from './theater-button'
diff --git a/client/src/assets/player/shared/control-bar/peertube-live-display.ts b/client/src/assets/player/shared/control-bar/peertube-live-display.ts
new file mode 100644
index 000000000..649eb0b00
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/peertube-live-display.ts
@@ -0,0 +1,93 @@
1import videojs from 'video.js'
2import { PeerTubeLinkButtonOptions } from '../../types'
3
4const ClickableComponent = videojs.getComponent('ClickableComponent')
5
6class PeerTubeLiveDisplay extends ClickableComponent {
7 private interval: any
8
9 private contentEl_: any
10
11 constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) {
12 super(player, options as any)
13
14 this.interval = this.setInterval(() => this.updateClass(), 1000)
15
16 this.show()
17 this.updateSync(true)
18 }
19
20 dispose () {
21 if (this.interval) {
22 this.clearInterval(this.interval)
23 this.interval = undefined
24 }
25
26 this.contentEl_ = null
27
28 super.dispose()
29 }
30
31 createEl () {
32 const el = super.createEl('div', {
33 className: 'vjs-live-control vjs-control'
34 })
35
36 this.contentEl_ = videojs.dom.createEl('div', {
37 className: 'vjs-live-display'
38 }, {
39 'aria-live': 'off'
40 })
41
42 this.contentEl_.appendChild(videojs.dom.createEl('span', {
43 className: 'vjs-control-text',
44 textContent: `${this.localize('Stream Type')}\u00a0`
45 }))
46
47 this.contentEl_.appendChild(document.createTextNode(this.localize('LIVE')))
48
49 el.appendChild(this.contentEl_)
50 return el
51 }
52
53 handleClick () {
54 const hlsjs = this.getHLSJS()
55 if (!hlsjs) return
56
57 this.player().currentTime(hlsjs.liveSyncPosition)
58 this.player().play()
59 this.updateSync(true)
60 }
61
62 private updateClass () {
63 const hlsjs = this.getHLSJS()
64 if (!hlsjs) return
65
66 // Not loaded yet
67 if (this.player().currentTime() === 0) return
68
69 const isSync = Math.abs(this.player().currentTime() - hlsjs.liveSyncPosition) < 10
70 this.updateSync(isSync)
71 }
72
73 private updateSync (isSync: boolean) {
74 if (isSync) {
75 this.addClass('synced-with-live-edge')
76 this.removeAttribute('title')
77 this.disable()
78 } else {
79 this.removeClass('synced-with-live-edge')
80 this.setAttribute('title', this.localize('Go back to the live'))
81 this.enable()
82 }
83 }
84
85 private getHLSJS () {
86 const p2pMediaLoader = this.player()?.p2pMediaLoader
87 if (!p2pMediaLoader) return undefined
88
89 return p2pMediaLoader().getHLSJS()
90 }
91}
92
93videojs.registerComponent('PeerTubeLiveDisplay', PeerTubeLiveDisplay)
diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
index ec1e1038b..f5b4b3919 100644
--- a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
+++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
@@ -4,6 +4,10 @@ type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardE
4 4
5const Plugin = videojs.getPlugin('plugin') 5const Plugin = videojs.getPlugin('plugin')
6 6
7export type HotkeysOptions = {
8 isLive: boolean
9}
10
7class PeerTubeHotkeysPlugin extends Plugin { 11class PeerTubeHotkeysPlugin extends Plugin {
8 private static readonly VOLUME_STEP = 0.1 12 private static readonly VOLUME_STEP = 0.1
9 private static readonly SEEK_STEP = 5 13 private static readonly SEEK_STEP = 5
@@ -12,9 +16,13 @@ class PeerTubeHotkeysPlugin extends Plugin {
12 16
13 private readonly handlers: KeyHandler[] 17 private readonly handlers: KeyHandler[]
14 18
15 constructor (player: videojs.Player, options: videojs.PlayerOptions) { 19 private readonly isLive: boolean
20
21 constructor (player: videojs.Player, options: videojs.PlayerOptions & HotkeysOptions) {
16 super(player, options) 22 super(player, options)
17 23
24 this.isLive = options.isLive
25
18 this.handlers = this.buildHandlers() 26 this.handlers = this.buildHandlers()
19 27
20 this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event) 28 this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event)
@@ -68,28 +76,6 @@ class PeerTubeHotkeysPlugin extends Plugin {
68 } 76 }
69 }, 77 },
70 78
71 // Rewind
72 {
73 accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
74 cb: e => {
75 e.preventDefault()
76
77 const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
78 this.player.currentTime(target)
79 }
80 },
81
82 // Forward
83 {
84 accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
85 cb: e => {
86 e.preventDefault()
87
88 const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
89 this.player.currentTime(target)
90 }
91 },
92
93 // Fullscreen 79 // Fullscreen
94 { 80 {
95 // f key or Ctrl + Enter 81 // f key or Ctrl + Enter
@@ -116,6 +102,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
116 { 102 {
117 accept: e => e.key === '>', 103 accept: e => e.key === '>',
118 cb: () => { 104 cb: () => {
105 if (this.isLive) return
106
119 const target = Math.min(this.player.playbackRate() + 0.1, 5) 107 const target = Math.min(this.player.playbackRate() + 0.1, 5)
120 108
121 this.player.playbackRate(parseFloat(target.toFixed(2))) 109 this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -126,6 +114,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
126 { 114 {
127 accept: e => e.key === '<', 115 accept: e => e.key === '<',
128 cb: () => { 116 cb: () => {
117 if (this.isLive) return
118
129 const target = Math.max(this.player.playbackRate() - 0.1, 0.10) 119 const target = Math.max(this.player.playbackRate() - 0.1, 0.10)
130 120
131 this.player.playbackRate(parseFloat(target.toFixed(2))) 121 this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -136,6 +126,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
136 { 126 {
137 accept: e => e.key === ',', 127 accept: e => e.key === ',',
138 cb: () => { 128 cb: () => {
129 if (this.isLive) return
130
139 this.player.pause() 131 this.player.pause()
140 132
141 // Calculate movement distance (assuming 30 fps) 133 // Calculate movement distance (assuming 30 fps)
@@ -148,6 +140,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
148 { 140 {
149 accept: e => e.key === '.', 141 accept: e => e.key === '.',
150 cb: () => { 142 cb: () => {
143 if (this.isLive) return
144
151 this.player.pause() 145 this.player.pause()
152 146
153 // Calculate movement distance (assuming 30 fps) 147 // Calculate movement distance (assuming 30 fps)
@@ -157,11 +151,47 @@ class PeerTubeHotkeysPlugin extends Plugin {
157 } 151 }
158 ] 152 ]
159 153
154 if (this.isLive) return handlers
155
156 return handlers.concat(this.buildVODHandlers())
157 }
158
159 private buildVODHandlers () {
160 const handlers: KeyHandler[] = [
161 // Rewind
162 {
163 accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
164 cb: e => {
165 if (this.isLive) return
166
167 e.preventDefault()
168
169 const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
170 this.player.currentTime(target)
171 }
172 },
173
174 // Forward
175 {
176 accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
177 cb: e => {
178 if (this.isLive) return
179
180 e.preventDefault()
181
182 const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
183 this.player.currentTime(target)
184 }
185 }
186 ]
187
160 // 0-9 key handlers 188 // 0-9 key handlers
161 for (let i = 0; i < 10; i++) { 189 for (let i = 0; i < 10; i++) {
162 handlers.push({ 190 handlers.push({
163 accept: e => this.isNakedOrShift(e, i + ''), 191 accept: e => this.isNakedOrShift(e, i + ''),
164 cb: e => { 192 cb: e => {
193 if (this.isLive) return
194
165 e.preventDefault() 195 e.preventDefault()
166 196
167 this.player.currentTime(this.player.duration() * i * 0.1) 197 this.player.currentTime(this.player.duration() * i * 0.1)
diff --git a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
index 27f366732..26f923e92 100644
--- a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
+++ b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
@@ -30,10 +30,7 @@ export class ControlBarOptionsBuilder {
30 } 30 }
31 31
32 Object.assign(children, { 32 Object.assign(children, {
33 currentTimeDisplay: {}, 33 ...this.getTimeControls(),
34 timeDivider: {},
35 durationDisplay: {},
36 liveDisplay: {},
37 34
38 flexibleWidthSpacer: {}, 35 flexibleWidthSpacer: {},
39 36
@@ -74,7 +71,9 @@ export class ControlBarOptionsBuilder {
74 private getSettingsButton () { 71 private getSettingsButton () {
75 const settingEntries: string[] = [] 72 const settingEntries: string[] = []
76 73
77 settingEntries.push('playbackRateMenuButton') 74 if (!this.options.isLive) {
75 settingEntries.push('playbackRateMenuButton')
76 }
78 77
79 if (this.options.captions === true) settingEntries.push('captionsButton') 78 if (this.options.captions === true) settingEntries.push('captionsButton')
80 79
@@ -90,7 +89,23 @@ export class ControlBarOptionsBuilder {
90 } 89 }
91 } 90 }
92 91
92 private getTimeControls () {
93 if (this.options.isLive) {
94 return {
95 peerTubeLiveDisplay: {}
96 }
97 }
98
99 return {
100 currentTimeDisplay: {},
101 timeDivider: {},
102 durationDisplay: {}
103 }
104 }
105
93 private getProgressControl () { 106 private getProgressControl () {
107 if (this.options.isLive) return {}
108
94 const loadProgressBar = this.mode === 'webtorrent' 109 const loadProgressBar = this.mode === 'webtorrent'
95 ? 'peerTubeLoadProgressBar' 110 ? 'peerTubeLoadProgressBar'
96 : 'loadProgressBar' 111 : 'loadProgressBar'
diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
index a14beb347..7f7d90ab9 100644
--- a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
+++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
@@ -281,8 +281,8 @@ class Html5Hlsjs {
281 if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1 281 if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1
282 else this.errorCounts[data.type] = 1 282 else this.errorCounts[data.type] = 1
283 283
284 if (data.fatal) logger.warn(error.message) 284 if (data.fatal) logger.error(error.message, { currentTime: this.player.currentTime(), data })
285 else logger.error(error.message, { data }) 285 else logger.warn(error.message)
286 286
287 if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) { 287 if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) {
288 error.code = 2 288 error.code = 2
diff --git a/client/src/assets/player/shared/stats/stats-card.ts b/client/src/assets/player/shared/stats/stats-card.ts
index f23ae48be..471a5e46c 100644
--- a/client/src/assets/player/shared/stats/stats-card.ts
+++ b/client/src/assets/player/shared/stats/stats-card.ts
@@ -182,7 +182,7 @@ class StatsCard extends Component {
182 let colorSpace = 'unknown' 182 let colorSpace = 'unknown'
183 let codecs = 'unknown' 183 let codecs = 'unknown'
184 184
185 if (metadata?.streams[0]) { 185 if (metadata?.streams?.[0]) {
186 const stream = metadata.streams[0] 186 const stream = metadata.streams[0]
187 187
188 colorSpace = stream['color_space'] !== 'unknown' 188 colorSpace = stream['color_space'] !== 'unknown'
@@ -193,7 +193,7 @@ class StatsCard extends Component {
193 } 193 }
194 194
195 const resolution = videoFile?.resolution.label + videoFile?.fps 195 const resolution = videoFile?.resolution.label + videoFile?.fps
196 const buffer = this.timeRangesToString(this.player().buffered()) 196 const buffer = this.timeRangesToString(this.player_.buffered())
197 const progress = this.player_.webtorrent().getTorrent()?.progress 197 const progress = this.player_.webtorrent().getTorrent()?.progress
198 198
199 return { 199 return {
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts
index 3057a5adb..3fbcec29c 100644
--- a/client/src/assets/player/types/manager-options.ts
+++ b/client/src/assets/player/types/manager-options.ts
@@ -29,6 +29,8 @@ export interface CustomizationOptions {
29 resume?: string 29 resume?: string
30 30
31 peertubeLink: boolean 31 peertubeLink: boolean
32
33 playbackRate?: number | string
32} 34}
33 35
34export interface CommonOptions extends CustomizationOptions { 36export interface CommonOptions extends CustomizationOptions {
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts
index c60154f3b..5674f78cb 100644
--- a/client/src/assets/player/types/peertube-videojs-typings.ts
+++ b/client/src/assets/player/types/peertube-videojs-typings.ts
@@ -3,6 +3,7 @@ import videojs from 'video.js'
3import { Engine } from '@peertube/p2p-media-loader-hlsjs' 3import { Engine } from '@peertube/p2p-media-loader-hlsjs'
4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' 4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
5import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' 5import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
6import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin'
6import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' 7import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
7import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' 8import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
8import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' 9import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
@@ -44,7 +45,7 @@ declare module 'video.js' {
44 45
45 bezels (): void 46 bezels (): void
46 peertubeMobile (): void 47 peertubeMobile (): void
47 peerTubeHotkeysPlugin (): void 48 peerTubeHotkeysPlugin (options?: HotkeysOptions): void
48 49
49 stats (options?: StatsCardOptions): StatsForNerdsPlugin 50 stats (options?: StatsCardOptions): StatsForNerdsPlugin
50 51
diff --git a/client/src/root-helpers/logger.ts b/client/src/root-helpers/logger.ts
index d1fdf73aa..618be62cd 100644
--- a/client/src/root-helpers/logger.ts
+++ b/client/src/root-helpers/logger.ts
@@ -27,6 +27,10 @@ class Logger {
27 warn (message: LoggerMessage, meta?: LoggerMeta) { 27 warn (message: LoggerMessage, meta?: LoggerMeta) {
28 this.runHooks('warn', message, meta) 28 this.runHooks('warn', message, meta)
29 29
30 this.clientWarn(message, meta)
31 }
32
33 clientWarn (message: LoggerMessage, meta?: LoggerMeta) {
30 if (meta) console.warn(message, meta) 34 if (meta) console.warn(message, meta)
31 else console.warn(message) 35 else console.warn(message)
32 } 36 }
@@ -34,6 +38,10 @@ class Logger {
34 error (message: LoggerMessage, meta?: LoggerMeta) { 38 error (message: LoggerMessage, meta?: LoggerMeta) {
35 this.runHooks('error', message, meta) 39 this.runHooks('error', message, meta)
36 40
41 this.clientError(message, meta)
42 }
43
44 clientError (message: LoggerMessage, meta?: LoggerMeta) {
37 if (meta) console.error(message, meta) 45 if (meta) console.error(message, meta)
38 else console.error(message) 46 else console.error(message)
39 } 47 }
diff --git a/client/src/root-helpers/plugins-manager.ts b/client/src/root-helpers/plugins-manager.ts
index 6c64e2b01..e5b06a94c 100644
--- a/client/src/root-helpers/plugins-manager.ts
+++ b/client/src/root-helpers/plugins-manager.ts
@@ -3,7 +3,7 @@ import * as debug from 'debug'
3import { firstValueFrom, ReplaySubject } from 'rxjs' 3import { firstValueFrom, ReplaySubject } from 'rxjs'
4import { first, shareReplay } from 'rxjs/operators' 4import { first, shareReplay } from 'rxjs/operators'
5import { RegisterClientHelpers } from 'src/types/register-client-option.model' 5import { RegisterClientHelpers } from 'src/types/register-client-option.model'
6import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' 6import { getExternalAuthHref, getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
7import { 7import {
8 ClientHookName, 8 ClientHookName,
9 clientHookObject, 9 clientHookObject,
@@ -16,7 +16,6 @@ import {
16 RegisterClientRouteOptions, 16 RegisterClientRouteOptions,
17 RegisterClientSettingsScriptOptions, 17 RegisterClientSettingsScriptOptions,
18 RegisterClientVideoFieldOptions, 18 RegisterClientVideoFieldOptions,
19 RegisteredExternalAuthConfig,
20 ServerConfigPlugin 19 ServerConfigPlugin
21} from '@shared/models' 20} from '@shared/models'
22import { environment } from '../environments/environment' 21import { environment } from '../environments/environment'
@@ -94,9 +93,13 @@ class PluginsManager {
94 return isTheme ? '/themes' : '/plugins' 93 return isTheme ? '/themes' : '/plugins'
95 } 94 }
96 95
97 static getExternalAuthHref (auth: RegisteredExternalAuthConfig) { 96 static getDefaultLoginHref (apiUrl: string, serverConfig: HTMLServerConfig) {
98 return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` 97 if (!serverConfig || serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
99 98
99 const externalAuths = serverConfig.plugin.registeredExternalAuths
100 if (externalAuths.length !== 1) return undefined
101
102 return getExternalAuthHref(apiUrl, externalAuths[0])
100 } 103 }
101 104
102 loadPluginsList (config: HTMLServerConfig) { 105 loadPluginsList (config: HTMLServerConfig) {
diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss
index 0082378e4..96b3adf66 100644
--- a/client/src/sass/player/control-bar.scss
+++ b/client/src/sass/player/control-bar.scss
@@ -153,8 +153,25 @@
153 } 153 }
154 154
155 .vjs-live-control { 155 .vjs-live-control {
156 line-height: $control-bar-height; 156 padding: 5px 7px;
157 min-width: 4em; 157 border-radius: 3px;
158 height: fit-content;
159 margin: auto 10px;
160 font-weight: bold;
161 max-width: fit-content;
162 opacity: 1 !important;
163 line-height: normal;
164 position: relative;
165 top: -1px;
166
167 &.synced-with-live-edge {
168 background: #d7281c;
169 }
170
171 &:not(.synced-with-live-edge) {
172 cursor: pointer;
173 background: #80807f;
174 }
158 } 175 }
159 176
160 .vjs-peertube { 177 .vjs-peertube {
diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss
index 88f6efb6a..ee66a9db3 100644
--- a/client/src/sass/primeng-custom.scss
+++ b/client/src/sass/primeng-custom.scss
@@ -294,6 +294,7 @@ body .p-datepicker .p-datepicker-header .p-datepicker-title select:focus {
294body .p-datepicker table { 294body .p-datepicker table {
295 font-size: 14px; 295 font-size: 14px;
296 margin: 0.857em 0 0 0; 296 margin: 0.857em 0 0 0;
297 table-layout: fixed;
297} 298}
298body .p-datepicker table th { 299body .p-datepicker table th {
299 padding: 0.5em; 300 padding: 0.5em;
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts
index b0bdb2dd9..f09c86d14 100644
--- a/client/src/standalone/videos/shared/player-manager-options.ts
+++ b/client/src/standalone/videos/shared/player-manager-options.ts
@@ -38,6 +38,7 @@ export class PlayerManagerOptions {
38 private enableApi = false 38 private enableApi = false
39 private startTime: number | string = 0 39 private startTime: number | string = 0
40 private stopTime: number | string 40 private stopTime: number | string
41 private playbackRate: number | string
41 42
42 private title: boolean 43 private title: boolean
43 private warningTitle: boolean 44 private warningTitle: boolean
@@ -130,6 +131,7 @@ export class PlayerManagerOptions {
130 this.subtitle = getParamString(params, 'subtitle') 131 this.subtitle = getParamString(params, 'subtitle')
131 this.startTime = getParamString(params, 'start') 132 this.startTime = getParamString(params, 'start')
132 this.stopTime = getParamString(params, 'stop') 133 this.stopTime = getParamString(params, 'stop')
134 this.playbackRate = getParamString(params, 'playbackRate')
133 135
134 this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor') 136 this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
135 this.foregroundColor = getParamString(params, 'foregroundColor') 137 this.foregroundColor = getParamString(params, 'foregroundColor')
@@ -210,6 +212,8 @@ export class PlayerManagerOptions {
210 ? playlistTracker.getCurrentElement().stopTimestamp 212 ? playlistTracker.getCurrentElement().stopTimestamp
211 : this.stopTime, 213 : this.stopTime,
212 214
215 playbackRate: this.playbackRate,
216
213 videoCaptions, 217 videoCaptions,
214 inactivityTimeout: 2500, 218 inactivityTimeout: 2500,
215 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid), 219 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
diff --git a/client/yarn.lock b/client/yarn.lock
index b680bfdfb..6a5456283 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -1789,10 +1789,10 @@
1789 read-package-json-fast "^2.0.3" 1789 read-package-json-fast "^2.0.3"
1790 which "^2.0.2" 1790 which "^2.0.2"
1791 1791
1792"@peertube/p2p-media-loader-core@^1.0.13", "@peertube/p2p-media-loader-core@^1.0.8": 1792"@peertube/p2p-media-loader-core@^1.0.14":
1793 version "1.0.13" 1793 version "1.0.14"
1794 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.13.tgz#36744a291b69c001b2562c1a93017979f8534ff8" 1794 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.14.tgz#b4442dd343d6b30a51502e1240275eb98ef2c788"
1795 integrity sha512-ArSAaeuxwwBAG0Xd3Gj0TzKObLfJFYzHz9+fREvmUf+GZQEG6qGwWmrdVWL6xjPiEuo6LdFeCOnHSQzAbj/ptg== 1795 integrity sha512-tjQv1CNziNY+zYzcL1h4q6AA2WuBUZnBIeVyjWR/EsO1EEC1VMdvPsL02cqYLz9yvIxgycjeTsWCm6XDqNgXRw==
1796 dependencies: 1796 dependencies:
1797 bittorrent-tracker "^9.19.0" 1797 bittorrent-tracker "^9.19.0"
1798 debug "^4.3.4" 1798 debug "^4.3.4"
@@ -1800,12 +1800,12 @@
1800 sha.js "^2.4.11" 1800 sha.js "^2.4.11"
1801 simple-peer "^9.11.1" 1801 simple-peer "^9.11.1"
1802 1802
1803"@peertube/p2p-media-loader-hlsjs@^1.0.13": 1803"@peertube/p2p-media-loader-hlsjs@^1.0.14":
1804 version "1.0.13" 1804 version "1.0.14"
1805 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.13.tgz#5305e2008041d01850802544d1c49298f79dd67a" 1805 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.14.tgz#829629a57608b0e30f4b50bc98578e6bee9f8b9b"
1806 integrity sha512-2BO2oaRsSHEhLkgi2iw1r4n1Yqq1EnyoOgOZccPDqjmHUsZSV/wNrno8WYr6LsleudrHA26Imu57hVD1jDx7lg== 1806 integrity sha512-ySUVgUvAFXCE5E94xxjfywQ8xzk3jy9UGVkgi5Oqq+QeY7uG+o7CZ+LsQ/RjXgWBD70tEnyyfADHtL+9FCnwyQ==
1807 dependencies: 1807 dependencies:
1808 "@peertube/p2p-media-loader-core" "^1.0.8" 1808 "@peertube/p2p-media-loader-core" "^1.0.14"
1809 debug "^4.3.4" 1809 debug "^4.3.4"
1810 events "^3.3.0" 1810 events "^3.3.0"
1811 m3u8-parser "^4.7.1" 1811 m3u8-parser "^4.7.1"
diff --git a/config/default.yaml b/config/default.yaml
index 20094ae8f..b2c418a0a 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -37,6 +37,11 @@ rates_limit:
37 window: 10 minutes 37 window: 10 minutes
38 max: 10 38 max: 10
39 39
40oauth2:
41 token_lifetime:
42 access_token: '1 day'
43 refresh_token: '2 weeks'
44
40# Proxies to trust to get real client IP 45# Proxies to trust to get real client IP
41# If you run PeerTube just behind a local proxy (nginx), keep 'loopback' 46# If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
42# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) 47# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
diff --git a/config/production.yaml.example b/config/production.yaml.example
index e8b354d01..36fa70417 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -35,6 +35,11 @@ rates_limit:
35 window: 10 minutes 35 window: 10 minutes
36 max: 10 36 max: 10
37 37
38oauth2:
39 token_lifetime:
40 access_token: '1 day'
41 refresh_token: '2 weeks'
42
38# Proxies to trust to get real client IP 43# Proxies to trust to get real client IP
39# If you run PeerTube just behind a local proxy (nginx), keep 'loopback' 44# If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
40# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) 45# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
diff --git a/package.json b/package.json
index d7d19afc2..b48f65bbd 100644
--- a/package.json
+++ b/package.json
@@ -225,7 +225,7 @@
225 "swagger-cli": "^4.0.2", 225 "swagger-cli": "^4.0.2",
226 "ts-node": "^10.8.1", 226 "ts-node": "^10.8.1",
227 "tsc-watch": "^5.0.3", 227 "tsc-watch": "^5.0.3",
228 "typescript": "^4.0.5" 228 "typescript": "~4.8"
229 }, 229 },
230 "bundlewatch": { 230 "bundlewatch": {
231 "files": [ 231 "files": [
diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts
index bcd7fe2a2..3b5045954 100755
--- a/scripts/i18n/create-custom-files.ts
+++ b/scripts/i18n/create-custom-files.ts
@@ -41,6 +41,7 @@ const playerKeys = {
41 'Volume': 'Volume', 41 'Volume': 'Volume',
42 'Codecs': 'Codecs', 42 'Codecs': 'Codecs',
43 'Color': 'Color', 43 'Color': 'Color',
44 'Go back to the live': 'Go back to the live',
44 'Connection Speed': 'Connection Speed', 45 'Connection Speed': 'Connection Speed',
45 'Network Activity': 'Network Activity', 46 'Network Activity': 'Network Activity',
46 'Total Transfered': 'Total Transfered', 47 'Total Transfered': 'Total Transfered',
diff --git a/server.ts b/server.ts
index dd595e951..f6a153fb7 100644
--- a/server.ts
+++ b/server.ts
@@ -279,7 +279,7 @@ app.use((err, _req, res: express.Response, _next) => {
279 }) 279 })
280}) 280})
281 281
282const server = createWebsocketTrackerServer(app) 282const { server, trackerServer } = createWebsocketTrackerServer(app)
283 283
284// ----------- Run ----------- 284// ----------- Run -----------
285 285
@@ -328,7 +328,8 @@ async function startApplication () {
328 VideoChannelSyncLatestScheduler.Instance.enable() 328 VideoChannelSyncLatestScheduler.Instance.enable()
329 VideoViewsBufferScheduler.Instance.enable() 329 VideoViewsBufferScheduler.Instance.enable()
330 GeoIPUpdateScheduler.Instance.enable() 330 GeoIPUpdateScheduler.Instance.enable()
331 OpenTelemetryMetrics.Instance.registerMetrics() 331
332 OpenTelemetryMetrics.Instance.registerMetrics({ trackerServer })
332 333
333 PluginManager.Instance.init(server) 334 PluginManager.Instance.init(server)
334 // Before PeerTubeSocket init 335 // Before PeerTubeSocket init
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 8e064fb5b..def320730 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -309,7 +309,7 @@ async function videoCommentsController (req: express.Request, res: express.Respo
309 if (redirectIfNotOwned(video.url, res)) return 309 if (redirectIfNotOwned(video.url, res)) return
310 310
311 const handler = async (start: number, count: number) => { 311 const handler = async (start: number, count: number) => {
312 const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count) 312 const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
313 313
314 return { 314 return {
315 total: result.total, 315 total: result.total,
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index 44d64776c..70ca21500 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -1,4 +1,6 @@
1import { MCommentFormattable } from '@server/types/models'
1import express from 'express' 2import express from 'express'
3
2import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models' 4import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 5import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model' 6import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
@@ -109,7 +111,7 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
109 const video = res.locals.onlyVideo 111 const video = res.locals.onlyVideo
110 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 112 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
111 113
112 let resultList: ThreadsResultList<VideoCommentModel> 114 let resultList: ThreadsResultList<MCommentFormattable>
113 115
114 if (video.commentsEnabled === true) { 116 if (video.commentsEnabled === true) {
115 const apiOptions = await Hooks.wrapObject({ 117 const apiOptions = await Hooks.wrapObject({
@@ -144,12 +146,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
144 const video = res.locals.onlyVideo 146 const video = res.locals.onlyVideo
145 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 147 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
146 148
147 let resultList: ResultList<VideoCommentModel> 149 let resultList: ResultList<MCommentFormattable>
148 150
149 if (video.commentsEnabled === true) { 151 if (video.commentsEnabled === true) {
150 const apiOptions = await Hooks.wrapObject({ 152 const apiOptions = await Hooks.wrapObject({
151 videoId: video.id, 153 videoId: video.id,
152 isVideoOwned: video.isOwned(),
153 threadId: res.locals.videoCommentThread.id, 154 threadId: res.locals.videoCommentThread.id,
154 user 155 user
155 }, 'filter:api.video-thread-comments.list.params') 156 }, 'filter:api.video-thread-comments.list.params')
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts
index 009b6dfb6..22387c3e8 100644
--- a/server/controllers/api/videos/token.ts
+++ b/server/controllers/api/videos/token.ts
@@ -22,7 +22,7 @@ export {
22function generateToken (req: express.Request, res: express.Response) { 22function generateToken (req: express.Request, res: express.Response) {
23 const video = res.locals.onlyVideo 23 const video = res.locals.onlyVideo
24 24
25 const { token, expires } = VideoTokensManager.Instance.create(video.uuid) 25 const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
26 26
27 return res.json({ 27 return res.json({
28 files: { 28 files: {
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index 772fe734d..ef810a842 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -285,8 +285,8 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
285 content: toSafeHtml(video.description), 285 content: toSafeHtml(video.description),
286 author: [ 286 author: [
287 { 287 {
288 name: video.VideoChannel.Account.getDisplayName(), 288 name: video.VideoChannel.getDisplayName(),
289 link: video.VideoChannel.Account.Actor.url 289 link: video.VideoChannel.Actor.url
290 } 290 }
291 ], 291 ],
292 date: video.publishedAt, 292 date: video.publishedAt,
diff --git a/server/controllers/tracker.ts b/server/controllers/tracker.ts
index 19a8b2bc9..c4f3a8889 100644
--- a/server/controllers/tracker.ts
+++ b/server/controllers/tracker.ts
@@ -1,17 +1,22 @@
1import { Server as TrackerServer } from 'bittorrent-tracker' 1import { Server as TrackerServer } from 'bittorrent-tracker'
2import express from 'express' 2import express from 'express'
3import { createServer } from 'http' 3import { createServer } from 'http'
4import LRUCache from 'lru-cache'
4import proxyAddr from 'proxy-addr' 5import proxyAddr from 'proxy-addr'
5import { WebSocketServer } from 'ws' 6import { WebSocketServer } from 'ws'
6import { Redis } from '@server/lib/redis'
7import { logger } from '../helpers/logger' 7import { logger } from '../helpers/logger'
8import { CONFIG } from '../initializers/config' 8import { CONFIG } from '../initializers/config'
9import { TRACKER_RATE_LIMITS } from '../initializers/constants' 9import { LRU_CACHE, TRACKER_RATE_LIMITS } from '../initializers/constants'
10import { VideoFileModel } from '../models/video/video-file' 10import { VideoFileModel } from '../models/video/video-file'
11import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 11import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
12 12
13const trackerRouter = express.Router() 13const trackerRouter = express.Router()
14 14
15const blockedIPs = new LRUCache<string, boolean>({
16 max: LRU_CACHE.TRACKER_IPS.MAX_SIZE,
17 ttl: TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME
18})
19
15let peersIps = {} 20let peersIps = {}
16let peersIpInfoHash = {} 21let peersIpInfoHash = {}
17runPeersChecker() 22runPeersChecker()
@@ -55,8 +60,7 @@ const trackerServer = new TrackerServer({
55 60
56 // Close socket connection and block IP for a few time 61 // Close socket connection and block IP for a few time
57 if (params.type === 'ws') { 62 if (params.type === 'ws') {
58 Redis.Instance.setTrackerBlockIP(ip) 63 blockedIPs.set(ip, true)
59 .catch(err => logger.error('Cannot set tracker block ip.', { err }))
60 64
61 // setTimeout to wait filter response 65 // setTimeout to wait filter response
62 setTimeout(() => params.socket.close(), 0) 66 setTimeout(() => params.socket.close(), 0)
@@ -102,26 +106,22 @@ function createWebsocketTrackerServer (app: express.Application) {
102 if (request.url === '/tracker/socket') { 106 if (request.url === '/tracker/socket') {
103 const ip = proxyAddr(request, CONFIG.TRUST_PROXY) 107 const ip = proxyAddr(request, CONFIG.TRUST_PROXY)
104 108
105 Redis.Instance.doesTrackerBlockIPExist(ip) 109 if (blockedIPs.has(ip)) {
106 .then(result => { 110 logger.debug('Blocking IP %s from tracker.', ip)
107 if (result === true) {
108 logger.debug('Blocking IP %s from tracker.', ip)
109 111
110 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n') 112 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
111 socket.destroy() 113 socket.destroy()
112 return 114 return
113 } 115 }
114 116
115 // FIXME: typings 117 // FIXME: typings
116 return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request)) 118 return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
117 })
118 .catch(err => logger.error('Cannot check if tracker block ip exists.', { err }))
119 } 119 }
120 120
121 // Don't destroy socket, we have Socket.IO too 121 // Don't destroy socket, we have Socket.IO too
122 }) 122 })
123 123
124 return server 124 return { server, trackerServer }
125} 125}
126 126
127// --------------------------------------------------------------------------- 127// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 3dc5504e3..b3ab3ac64 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -103,7 +103,13 @@ function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
103// --------------------------------------------------------------------------- 103// ---------------------------------------------------------------------------
104 104
105function toCompleteUUID (value: string) { 105function toCompleteUUID (value: string) {
106 if (isShortUUID(value)) return shortToUUID(value) 106 if (isShortUUID(value)) {
107 try {
108 return shortToUUID(value)
109 } catch {
110 return null
111 }
112 }
107 113
108 return value 114 return value
109} 115}
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
index 59ba005fe..d5b09ea03 100644
--- a/server/helpers/custom-validators/video-captions.ts
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -8,10 +8,11 @@ function isVideoCaptionLanguageValid (value: any) {
8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined 8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined
9} 9}
10 10
11const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) 11// MacOS sends application/octet-stream
12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 12const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ]
13 .map(m => `(${m})`) 13 .map(m => `(${m})`)
14 .join('|') 14 .join('|')
15
15function isVideoCaptionFile (files: UploadFilesForCheck, field: string) { 16function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
16 return isFileValid({ 17 return isFileValid({
17 files, 18 files,
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
index af93aea56..da8962cb6 100644
--- a/server/helpers/custom-validators/video-imports.ts
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -22,10 +22,11 @@ function isVideoImportStateValid (value: any) {
22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined 22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined
23} 23}
24 24
25const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT) 25// MacOS sends application/octet-stream
26 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 26const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ]
27 .map(m => `(${m})`) 27 .map(m => `(${m})`)
28 .join('|') 28 .join('|')
29
29function isVideoImportTorrentFile (files: UploadFilesForCheck) { 30function isVideoImportTorrentFile (files: UploadFilesForCheck) {
30 return isFileValid({ 31 return isFileValid({
31 files, 32 files,
diff --git a/server/helpers/decache.ts b/server/helpers/decache.ts
index e31973b7a..08ab545e4 100644
--- a/server/helpers/decache.ts
+++ b/server/helpers/decache.ts
@@ -68,7 +68,7 @@ function searchCache (moduleName: string, callback: (current: NodeModule) => voi
68}; 68};
69 69
70function removeCachedPath (pluginPath: string) { 70function removeCachedPath (pluginPath: string) {
71 const pathCache = (module.constructor as any)._pathCache 71 const pathCache = (module.constructor as any)._pathCache as { [ id: string ]: string[] }
72 72
73 Object.keys(pathCache).forEach(function (cacheKey) { 73 Object.keys(pathCache).forEach(function (cacheKey) {
74 if (cacheKey.includes(pluginPath)) { 74 if (cacheKey.includes(pluginPath)) {
diff --git a/server/helpers/memoize.ts b/server/helpers/memoize.ts
new file mode 100644
index 000000000..aa20e7d73
--- /dev/null
+++ b/server/helpers/memoize.ts
@@ -0,0 +1,12 @@
1import memoizee from 'memoizee'
2
3export function Memoize (config?: memoizee.Options<any>) {
4 return function (_target, _key, descriptor: PropertyDescriptor) {
5 const oldFunction = descriptor.value
6 const newFunction = memoizee(oldFunction, config)
7
8 descriptor.value = function () {
9 return newFunction.apply(this, arguments)
10 }
11 }
12}
diff --git a/server/helpers/youtube-dl/youtube-dl-cli.ts b/server/helpers/youtube-dl/youtube-dl-cli.ts
index a2f630953..765038cea 100644
--- a/server/helpers/youtube-dl/youtube-dl-cli.ts
+++ b/server/helpers/youtube-dl/youtube-dl-cli.ts
@@ -6,6 +6,7 @@ import { VideoResolution } from '@shared/models'
6import { logger, loggerTagsFactory } from '../logger' 6import { logger, loggerTagsFactory } from '../logger'
7import { getProxy, isProxyEnabled } from '../proxy' 7import { getProxy, isProxyEnabled } from '../proxy'
8import { isBinaryResponse, peertubeGot } from '../requests' 8import { isBinaryResponse, peertubeGot } from '../requests'
9import { OptionsOfBufferResponseBody } from 'got/dist/source'
9 10
10const lTags = loggerTagsFactory('youtube-dl') 11const lTags = loggerTagsFactory('youtube-dl')
11 12
@@ -28,7 +29,16 @@ export class YoutubeDLCLI {
28 29
29 logger.info('Updating youtubeDL binary from %s.', url, lTags()) 30 logger.info('Updating youtubeDL binary from %s.', url, lTags())
30 31
31 const gotOptions = { context: { bodyKBLimit: 20_000 }, responseType: 'buffer' as 'buffer' } 32 const gotOptions: OptionsOfBufferResponseBody = {
33 context: { bodyKBLimit: 20_000 },
34 responseType: 'buffer' as 'buffer'
35 }
36
37 if (process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN) {
38 gotOptions.headers = {
39 authorization: 'Bearer ' + process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN
40 }
41 }
32 42
33 try { 43 try {
34 let gotResult = await peertubeGot(url, gotOptions) 44 let gotResult = await peertubeGot(url, gotOptions)
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index c83fef425..dc46b5126 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -174,7 +174,8 @@ function checkRemoteRedundancyConfig () {
174function checkStorageConfig () { 174function checkStorageConfig () {
175 // Check storage directory locations 175 // Check storage directory locations
176 if (isProdInstance()) { 176 if (isProdInstance()) {
177 const configStorage = config.get('storage') 177 const configStorage = config.get<{ [ name: string ]: string }>('storage')
178
178 for (const key of Object.keys(configStorage)) { 179 for (const key of Object.keys(configStorage)) {
179 if (configStorage[key].startsWith('storage/')) { 180 if (configStorage[key].startsWith('storage/')) {
180 logger.warn( 181 logger.warn(
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 39713a266..57852241c 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -13,6 +13,7 @@ function checkMissedConfig () {
13 'webserver.https', 'webserver.hostname', 'webserver.port', 13 'webserver.https', 'webserver.hostname', 'webserver.port',
14 'secrets.peertube', 14 'secrets.peertube',
15 'trust_proxy', 15 'trust_proxy',
16 'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token',
16 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', 17 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
17 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 18 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
18 'email.body.signature', 'email.subject.prefix', 19 'email.body.signature', 'email.subject.prefix',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index c2f8b19fd..28aaf36a9 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -149,6 +149,12 @@ const CONFIG = {
149 HOSTNAME: config.get<string>('webserver.hostname'), 149 HOSTNAME: config.get<string>('webserver.hostname'),
150 PORT: config.get<number>('webserver.port') 150 PORT: config.get<number>('webserver.port')
151 }, 151 },
152 OAUTH2: {
153 TOKEN_LIFETIME: {
154 ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')),
155 REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token'))
156 }
157 },
152 RATES_LIMIT: { 158 RATES_LIMIT: {
153 API: { 159 API: {
154 WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')), 160 WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')),
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 0e56f0c9f..0dab524d9 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -101,11 +101,6 @@ const SORTABLE_COLUMNS = {
101 VIDEO_REDUNDANCIES: [ 'name' ] 101 VIDEO_REDUNDANCIES: [ 'name' ]
102} 102}
103 103
104const OAUTH_LIFETIME = {
105 ACCESS_TOKEN: 3600 * 24, // 1 day, for upload
106 REFRESH_TOKEN: 1209600 // 2 weeks
107}
108
109const ROUTE_CACHE_LIFETIME = { 104const ROUTE_CACHE_LIFETIME = {
110 FEEDS: '15 minutes', 105 FEEDS: '15 minutes',
111 ROBOTS: '2 hours', 106 ROBOTS: '2 hours',
@@ -781,6 +776,9 @@ const LRU_CACHE = {
781 VIDEO_TOKENS: { 776 VIDEO_TOKENS: {
782 MAX_SIZE: 100_000, 777 MAX_SIZE: 100_000,
783 TTL: parseDurationToMs('8 hours') 778 TTL: parseDurationToMs('8 hours')
779 },
780 TRACKER_IPS: {
781 MAX_SIZE: 100_000
784 } 782 }
785} 783}
786 784
@@ -884,7 +882,7 @@ const TRACKER_RATE_LIMITS = {
884 INTERVAL: 60000 * 5, // 5 minutes 882 INTERVAL: 60000 * 5, // 5 minutes
885 ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval 883 ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval
886 ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval 884 ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval
887 BLOCK_IP_LIFETIME: 60000 * 3 // 3 minutes 885 BLOCK_IP_LIFETIME: parseDurationToMs('3 minutes')
888} 886}
889 887
890const P2P_MEDIA_LOADER_PEER_VERSION = 2 888const P2P_MEDIA_LOADER_PEER_VERSION = 2
@@ -1030,7 +1028,6 @@ export {
1030 JOB_ATTEMPTS, 1028 JOB_ATTEMPTS,
1031 AP_CLEANER, 1029 AP_CLEANER,
1032 LAST_MIGRATION_VERSION, 1030 LAST_MIGRATION_VERSION,
1033 OAUTH_LIFETIME,
1034 CUSTOM_HTML_TAG_COMMENTS, 1031 CUSTOM_HTML_TAG_COMMENTS,
1035 STATS_TIMESERIE, 1032 STATS_TIMESERIE,
1036 BROADCAST_CONCURRENCY, 1033 BROADCAST_CONCURRENCY,
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index f5d8eedf1..f48f348a7 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -51,8 +51,7 @@ function removeCacheAndTmpDirectories () {
51 const tasks: Promise<any>[] = [] 51 const tasks: Promise<any>[] = []
52 52
53 // Cache directories 53 // Cache directories
54 for (const key of Object.keys(cacheDirectories)) { 54 for (const dir of cacheDirectories) {
55 const dir = cacheDirectories[key]
56 tasks.push(removeDirectoryOrContent(dir)) 55 tasks.push(removeDirectoryOrContent(dir))
57 } 56 }
58 57
@@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () {
87 } 86 }
88 87
89 // Cache directories 88 // Cache directories
90 for (const key of Object.keys(cacheDirectories)) { 89 for (const dir of cacheDirectories) {
91 const dir = cacheDirectories[key]
92 tasks.push(ensureDir(dir)) 90 tasks.push(ensureDir(dir))
93 } 91 }
94 92
diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts
index 053112801..bc5b74257 100644
--- a/server/lib/auth/external-auth.ts
+++ b/server/lib/auth/external-auth.ts
@@ -1,26 +1,35 @@
1 1
2import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users' 2import {
3 isUserAdminFlagsValid,
4 isUserDisplayNameValid,
5 isUserRoleValid,
6 isUserUsernameValid,
7 isUserVideoQuotaDailyValid,
8 isUserVideoQuotaValid
9} from '@server/helpers/custom-validators/users'
3import { logger } from '@server/helpers/logger' 10import { logger } from '@server/helpers/logger'
4import { generateRandomString } from '@server/helpers/utils' 11import { generateRandomString } from '@server/helpers/utils'
5import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants' 12import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
6import { PluginManager } from '@server/lib/plugins/plugin-manager' 13import { PluginManager } from '@server/lib/plugins/plugin-manager'
7import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 14import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
15import { MUser } from '@server/types/models'
8import { 16import {
9 RegisterServerAuthenticatedResult, 17 RegisterServerAuthenticatedResult,
10 RegisterServerAuthPassOptions, 18 RegisterServerAuthPassOptions,
11 RegisterServerExternalAuthenticatedResult 19 RegisterServerExternalAuthenticatedResult
12} from '@server/types/plugins/register-server-auth.model' 20} from '@server/types/plugins/register-server-auth.model'
13import { UserRole } from '@shared/models' 21import { UserAdminFlag, UserRole } from '@shared/models'
22import { BypassLogin } from './oauth-model'
23
24export type ExternalUser =
25 Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
26 { displayName: string }
14 27
15// Token is the key, expiration date is the value 28// Token is the key, expiration date is the value
16const authBypassTokens = new Map<string, { 29const authBypassTokens = new Map<string, {
17 expires: Date 30 expires: Date
18 user: { 31 user: ExternalUser
19 username: string 32 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
20 email: string
21 displayName: string
22 role: UserRole
23 }
24 authName: string 33 authName: string
25 npmName: string 34 npmName: string
26}>() 35}>()
@@ -56,7 +65,8 @@ async function onExternalUserAuthenticated (options: {
56 expires, 65 expires,
57 user, 66 user,
58 npmName, 67 npmName,
59 authName 68 authName,
69 userUpdater: authResult.userUpdater
60 }) 70 })
61 71
62 // Cleanup expired tokens 72 // Cleanup expired tokens
@@ -78,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) {
78 return tokenModel?.authName 88 return tokenModel?.authName
79} 89}
80 90
81async function getBypassFromPasswordGrant (username: string, password: string) { 91async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
82 const plugins = PluginManager.Instance.getIdAndPassAuths() 92 const plugins = PluginManager.Instance.getIdAndPassAuths()
83 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] 93 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
84 94
@@ -133,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
133 bypass: true, 143 bypass: true,
134 pluginName: pluginAuth.npmName, 144 pluginName: pluginAuth.npmName,
135 authName: authOptions.authName, 145 authName: authOptions.authName,
136 user: buildUserResult(loginResult) 146 user: buildUserResult(loginResult),
147 userUpdater: loginResult.userUpdater
137 } 148 }
138 } catch (err) { 149 } catch (err) {
139 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) 150 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
@@ -143,7 +154,7 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
143 return undefined 154 return undefined
144} 155}
145 156
146function getBypassFromExternalAuth (username: string, externalAuthToken: string) { 157function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
147 const obj = authBypassTokens.get(externalAuthToken) 158 const obj = authBypassTokens.get(externalAuthToken)
148 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') 159 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
149 160
@@ -167,33 +178,29 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string)
167 bypass: true, 178 bypass: true,
168 pluginName: npmName, 179 pluginName: npmName,
169 authName, 180 authName,
181 userUpdater: obj.userUpdater,
170 user 182 user
171 } 183 }
172} 184}
173 185
174function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { 186function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
175 if (!isUserUsernameValid(result.username)) { 187 const returnError = (field: string) => {
176 logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username }) 188 logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
177 return false 189 return false
178 } 190 }
179 191
180 if (!result.email) { 192 if (!isUserUsernameValid(result.username)) return returnError('username')
181 logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email }) 193 if (!result.email) return returnError('email')
182 return false
183 }
184 194
185 // role is optional 195 // Following fields are optional
186 if (result.role && !isUserRoleValid(result.role)) { 196 if (result.role && !isUserRoleValid(result.role)) return returnError('role')
187 logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role }) 197 if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
188 return false 198 if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
189 } 199 if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
200 if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
190 201
191 // display name is optional 202 if (result.userUpdater && typeof result.userUpdater !== 'function') {
192 if (result.displayName && !isUserDisplayNameValid(result.displayName)) { 203 logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
193 logger.error(
194 'Auth method %s of plugin %s did not provide a valid display name.',
195 authName, npmName, { displayName: result.displayName }
196 )
197 return false 204 return false
198 } 205 }
199 206
@@ -205,7 +212,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
205 username: pluginResult.username, 212 username: pluginResult.username,
206 email: pluginResult.email, 213 email: pluginResult.email,
207 role: pluginResult.role ?? UserRole.USER, 214 role: pluginResult.role ?? UserRole.USER,
208 displayName: pluginResult.displayName || pluginResult.username 215 displayName: pluginResult.displayName || pluginResult.username,
216
217 adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
218
219 videoQuota: pluginResult.videoQuota,
220 videoQuotaDaily: pluginResult.videoQuotaDaily
209 } 221 }
210} 222}
211 223
diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts
index 322b69e3a..43909284f 100644
--- a/server/lib/auth/oauth-model.ts
+++ b/server/lib/auth/oauth-model.ts
@@ -1,11 +1,13 @@
1import express from 'express' 1import express from 'express'
2import { AccessDeniedError } from '@node-oauth/oauth2-server' 2import { AccessDeniedError } from '@node-oauth/oauth2-server'
3import { PluginManager } from '@server/lib/plugins/plugin-manager' 3import { PluginManager } from '@server/lib/plugins/plugin-manager'
4import { AccountModel } from '@server/models/account/account'
5import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types'
4import { MOAuthClient } from '@server/types/models' 6import { MOAuthClient } from '@server/types/models'
5import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 7import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
6import { MUser } from '@server/types/models/user/user' 8import { MUser, MUserDefault } from '@server/types/models/user/user'
7import { pick } from '@shared/core-utils' 9import { pick } from '@shared/core-utils'
8import { UserRole } from '@shared/models/users/user-role' 10import { AttributesOnly } from '@shared/typescript-utils'
9import { logger } from '../../helpers/logger' 11import { logger } from '../../helpers/logger'
10import { CONFIG } from '../../initializers/config' 12import { CONFIG } from '../../initializers/config'
11import { OAuthClientModel } from '../../models/oauth/oauth-client' 13import { OAuthClientModel } from '../../models/oauth/oauth-client'
@@ -13,6 +15,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token'
13import { UserModel } from '../../models/user/user' 15import { UserModel } from '../../models/user/user'
14import { findAvailableLocalActorName } from '../local-actor' 16import { findAvailableLocalActorName } from '../local-actor'
15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' 17import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
18import { ExternalUser } from './external-auth'
16import { TokensCache } from './tokens-cache' 19import { TokensCache } from './tokens-cache'
17 20
18type TokenInfo = { 21type TokenInfo = {
@@ -26,12 +29,8 @@ export type BypassLogin = {
26 bypass: boolean 29 bypass: boolean
27 pluginName: string 30 pluginName: string
28 authName?: string 31 authName?: string
29 user: { 32 user: ExternalUser
30 username: string 33 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
31 email: string
32 displayName: string
33 role: UserRole
34 }
35} 34}
36 35
37async function getAccessToken (bearerToken: string) { 36async function getAccessToken (bearerToken: string) {
@@ -89,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
89 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) 88 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
90 89
91 let user = await UserModel.loadByEmail(bypassLogin.user.email) 90 let user = await UserModel.loadByEmail(bypassLogin.user.email)
91
92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) 92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
93 else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
93 94
94 // Cannot create a user 95 // Cannot create a user
95 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.') 96 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
@@ -219,16 +220,11 @@ export {
219 220
220// --------------------------------------------------------------------------- 221// ---------------------------------------------------------------------------
221 222
222async function createUserFromExternal (pluginAuth: string, options: { 223async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
223 username: string 224 const username = await findAvailableLocalActorName(userOptions.username)
224 email: string
225 role: UserRole
226 displayName: string
227}) {
228 const username = await findAvailableLocalActorName(options.username)
229 225
230 const userToCreate = buildUser({ 226 const userToCreate = buildUser({
231 ...pick(options, [ 'email', 'role' ]), 227 ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
232 228
233 username, 229 username,
234 emailVerified: null, 230 emailVerified: null,
@@ -238,12 +234,57 @@ async function createUserFromExternal (pluginAuth: string, options: {
238 234
239 const { user } = await createUserAccountAndChannelAndPlaylist({ 235 const { user } = await createUserAccountAndChannelAndPlaylist({
240 userToCreate, 236 userToCreate,
241 userDisplayName: options.displayName 237 userDisplayName: userOptions.displayName
242 }) 238 })
243 239
244 return user 240 return user
245} 241}
246 242
243async function updateUserFromExternal (
244 user: MUserDefault,
245 userOptions: ExternalUser,
246 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
247) {
248 if (!userUpdater) return user
249
250 {
251 type UserAttributeKeys = keyof AttributesOnly<UserModel>
252 const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
253 role: 'role',
254 adminFlags: 'adminFlags',
255 videoQuota: 'videoQuota',
256 videoQuotaDaily: 'videoQuotaDaily'
257 }
258
259 for (const modelKey of Object.keys(mappingKeys)) {
260 const pluginOptionKey = mappingKeys[modelKey]
261
262 const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
263 user.set(modelKey, newValue)
264 }
265 }
266
267 {
268 type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
269 const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
270 name: 'displayName'
271 }
272
273 for (const modelKey of Object.keys(mappingKeys)) {
274 const optionKey = mappingKeys[modelKey]
275
276 const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
277 user.Account.set(modelKey, newValue)
278 }
279 }
280
281 logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
282
283 user.Account = await user.Account.save()
284
285 return user.save()
286}
287
247function checkUserValidityOrThrow (user: MUser) { 288function checkUserValidityOrThrow (user: MUser) {
248 if (user.blocked) throw new AccessDeniedError('User is blocked.') 289 if (user.blocked) throw new AccessDeniedError('User is blocked.')
249} 290}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
index bc0d4301f..2905c79a2 100644
--- a/server/lib/auth/oauth.ts
+++ b/server/lib/auth/oauth.ts
@@ -10,10 +10,11 @@ import OAuth2Server, {
10} from '@node-oauth/oauth2-server' 10} from '@node-oauth/oauth2-server'
11import { randomBytesPromise } from '@server/helpers/core-utils' 11import { randomBytesPromise } from '@server/helpers/core-utils'
12import { isOTPValid } from '@server/helpers/otp' 12import { isOTPValid } from '@server/helpers/otp'
13import { CONFIG } from '@server/initializers/config'
13import { MOAuthClient } from '@server/types/models' 14import { MOAuthClient } from '@server/types/models'
14import { sha1 } from '@shared/extra-utils' 15import { sha1 } from '@shared/extra-utils'
15import { HttpStatusCode } from '@shared/models' 16import { HttpStatusCode } from '@shared/models'
16import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' 17import { OTP } from '../../initializers/constants'
17import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' 18import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
18 19
19class MissingTwoFactorError extends Error { 20class MissingTwoFactorError extends Error {
@@ -32,8 +33,9 @@ class InvalidTwoFactorError extends Error {
32 * 33 *
33 */ 34 */
34const oAuthServer = new OAuth2Server({ 35const oAuthServer = new OAuth2Server({
35 accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, 36 // Wants seconds
36 refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, 37 accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
38 refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
37 39
38 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications 40 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
39 model: require('./oauth-model') 41 model: require('./oauth-model')
@@ -182,10 +184,10 @@ function generateRandomToken () {
182 184
183function getTokenExpiresAt (type: 'access' | 'refresh') { 185function getTokenExpiresAt (type: 'access' | 'refresh') {
184 const lifetime = type === 'access' 186 const lifetime = type === 'access'
185 ? OAUTH_LIFETIME.ACCESS_TOKEN 187 ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
186 : OAUTH_LIFETIME.REFRESH_TOKEN 188 : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
187 189
188 return new Date(Date.now() + lifetime * 1000) 190 return new Date(Date.now() + lifetime)
189} 191}
190 192
191async function buildToken () { 193async function buildToken () {
diff --git a/server/lib/auth/tokens-cache.ts b/server/lib/auth/tokens-cache.ts
index 410708a35..43efc7d02 100644
--- a/server/lib/auth/tokens-cache.ts
+++ b/server/lib/auth/tokens-cache.ts
@@ -36,8 +36,8 @@ export class TokensCache {
36 const token = this.userHavingToken.get(userId) 36 const token = this.userHavingToken.get(userId)
37 37
38 if (token !== undefined) { 38 if (token !== undefined) {
39 this.accessTokenCache.del(token) 39 this.accessTokenCache.delete(token)
40 this.userHavingToken.del(userId) 40 this.userHavingToken.delete(userId)
41 } 41 }
42 } 42 }
43 43
@@ -45,8 +45,8 @@ export class TokensCache {
45 const tokenModel = this.accessTokenCache.get(token) 45 const tokenModel = this.accessTokenCache.get(token)
46 46
47 if (tokenModel !== undefined) { 47 if (tokenModel !== undefined) {
48 this.userHavingToken.del(tokenModel.userId) 48 this.userHavingToken.delete(tokenModel.userId)
49 this.accessTokenCache.del(token) 49 this.accessTokenCache.delete(token)
50 } 50 }
51 } 51 }
52} 52}
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index 866aa1ed0..8597eb000 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -184,7 +184,7 @@ class JobQueue {
184 184
185 this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST 185 this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST
186 186
187 for (const handlerName of (Object.keys(handlers) as JobType[])) { 187 for (const handlerName of Object.keys(handlers)) {
188 this.buildWorker(handlerName) 188 this.buildWorker(handlerName)
189 this.buildQueue(handlerName) 189 this.buildQueue(handlerName)
190 this.buildQueueScheduler(handlerName) 190 this.buildQueueScheduler(handlerName)
diff --git a/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
new file mode 100644
index 000000000..ef40c0fa9
--- /dev/null
+++ b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
@@ -0,0 +1,51 @@
1import { Meter } from '@opentelemetry/api'
2
3export class BittorrentTrackerObserversBuilder {
4
5 constructor (private readonly meter: Meter, private readonly trackerServer: any) {
6
7 }
8
9 buildObservers () {
10 const activeInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_active_infohashes_total', {
11 description: 'Total active infohashes in the PeerTube BitTorrent Tracker'
12 })
13 const inactiveInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_inactive_infohashes_total', {
14 description: 'Total inactive infohashes in the PeerTube BitTorrent Tracker'
15 })
16 const peers = this.meter.createObservableGauge('peertube_bittorrent_tracker_peers_total', {
17 description: 'Total peers in the PeerTube BitTorrent Tracker'
18 })
19
20 this.meter.addBatchObservableCallback(observableResult => {
21 const infohashes = Object.keys(this.trackerServer.torrents)
22
23 const counters = {
24 activeInfohashes: 0,
25 inactiveInfohashes: 0,
26 peers: 0,
27 uncompletedPeers: 0
28 }
29
30 for (const infohash of infohashes) {
31 const content = this.trackerServer.torrents[infohash]
32
33 const peers = content.peers
34 if (peers.keys.length !== 0) counters.activeInfohashes++
35 else counters.inactiveInfohashes++
36
37 for (const peerId of peers.keys) {
38 const peer = peers.peek(peerId)
39 if (peer == null) return
40
41 counters.peers++
42 }
43 }
44
45 observableResult.observe(activeInfohashes, counters.activeInfohashes)
46 observableResult.observe(inactiveInfohashes, counters.inactiveInfohashes)
47 observableResult.observe(peers, counters.peers)
48 }, [ activeInfohashes, inactiveInfohashes, peers ])
49 }
50
51}
diff --git a/server/lib/opentelemetry/metric-helpers/index.ts b/server/lib/opentelemetry/metric-helpers/index.ts
index 775d954ba..47b24a54f 100644
--- a/server/lib/opentelemetry/metric-helpers/index.ts
+++ b/server/lib/opentelemetry/metric-helpers/index.ts
@@ -1,3 +1,4 @@
1export * from './bittorrent-tracker-observers-builder'
1export * from './lives-observers-builder' 2export * from './lives-observers-builder'
2export * from './job-queue-observers-builder' 3export * from './job-queue-observers-builder'
3export * from './nodejs-observers-builder' 4export * from './nodejs-observers-builder'
diff --git a/server/lib/opentelemetry/metrics.ts b/server/lib/opentelemetry/metrics.ts
index 226d514c0..9cc067e4a 100644
--- a/server/lib/opentelemetry/metrics.ts
+++ b/server/lib/opentelemetry/metrics.ts
@@ -7,6 +7,7 @@ import { CONFIG } from '@server/initializers/config'
7import { MVideoImmutable } from '@server/types/models' 7import { MVideoImmutable } from '@server/types/models'
8import { PlaybackMetricCreate } from '@shared/models' 8import { PlaybackMetricCreate } from '@shared/models'
9import { 9import {
10 BittorrentTrackerObserversBuilder,
10 JobQueueObserversBuilder, 11 JobQueueObserversBuilder,
11 LivesObserversBuilder, 12 LivesObserversBuilder,
12 NodeJSObserversBuilder, 13 NodeJSObserversBuilder,
@@ -41,7 +42,7 @@ class OpenTelemetryMetrics {
41 }) 42 })
42 } 43 }
43 44
44 registerMetrics () { 45 registerMetrics (options: { trackerServer: any }) {
45 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return 46 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return
46 47
47 logger.info('Registering Open Telemetry metrics') 48 logger.info('Registering Open Telemetry metrics')
@@ -80,6 +81,9 @@ class OpenTelemetryMetrics {
80 81
81 const viewersObserversBuilder = new ViewersObserversBuilder(this.meter) 82 const viewersObserversBuilder = new ViewersObserversBuilder(this.meter)
82 viewersObserversBuilder.buildObservers() 83 viewersObserversBuilder.buildObservers()
84
85 const bittorrentTrackerObserversBuilder = new BittorrentTrackerObserversBuilder(this.meter, options.trackerServer)
86 bittorrentTrackerObserversBuilder.buildObservers()
83 } 87 }
84 88
85 observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) { 89 observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
index 7b1def6e3..66383af46 100644
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ b/server/lib/plugins/plugin-helpers-builder.ts
@@ -209,6 +209,10 @@ function buildConfigHelpers () {
209 return WEBSERVER.URL 209 return WEBSERVER.URL
210 }, 210 },
211 211
212 getServerListeningConfig () {
213 return { hostname: CONFIG.LISTEN.HOSTNAME, port: CONFIG.LISTEN.PORT }
214 },
215
212 getServerConfig () { 216 getServerConfig () {
213 return ServerConfigManager.Instance.getServerConfig() 217 return ServerConfigManager.Instance.getServerConfig()
214 } 218 }
@@ -245,7 +249,7 @@ function buildUserHelpers () {
245 }, 249 },
246 250
247 getAuthUser: (res: express.Response) => { 251 getAuthUser: (res: express.Response) => {
248 const user = res.locals.oauth?.token?.User 252 const user = res.locals.oauth?.token?.User || res.locals.videoFileToken?.user
249 if (!user) return undefined 253 if (!user) return undefined
250 254
251 return UserModel.loadByIdFull(user.id) 255 return UserModel.loadByIdFull(user.id)
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index c0e9aece7..451ddd0b6 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -8,7 +8,6 @@ import {
8 AP_CLEANER, 8 AP_CLEANER,
9 CONTACT_FORM_LIFETIME, 9 CONTACT_FORM_LIFETIME,
10 RESUMABLE_UPLOAD_SESSION_LIFETIME, 10 RESUMABLE_UPLOAD_SESSION_LIFETIME,
11 TRACKER_RATE_LIMITS,
12 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, 11 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
13 USER_EMAIL_VERIFY_LIFETIME, 12 USER_EMAIL_VERIFY_LIFETIME,
14 USER_PASSWORD_CREATE_LIFETIME, 13 USER_PASSWORD_CREATE_LIFETIME,
@@ -157,16 +156,6 @@ class Redis {
157 return this.exists(this.generateIPViewKey(ip, videoUUID)) 156 return this.exists(this.generateIPViewKey(ip, videoUUID))
158 } 157 }
159 158
160 /* ************ Tracker IP block ************ */
161
162 setTrackerBlockIP (ip: string) {
163 return this.setValue(this.generateTrackerBlockIPKey(ip), '1', TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME)
164 }
165
166 async doesTrackerBlockIPExist (ip: string) {
167 return this.exists(this.generateTrackerBlockIPKey(ip))
168 }
169
170 /* ************ Video views stats ************ */ 159 /* ************ Video views stats ************ */
171 160
172 addVideoViewStats (videoId: number) { 161 addVideoViewStats (videoId: number) {
@@ -365,10 +354,6 @@ class Redis {
365 return `views-${videoUUID}-${ip}` 354 return `views-${videoUUID}-${ip}`
366 } 355 }
367 356
368 private generateTrackerBlockIPKey (ip: string) {
369 return `tracker-block-ip-${ip}`
370 }
371
372 private generateContactFormKey (ip: string) { 357 private generateContactFormKey (ip: string) {
373 return 'contact-form-' + ip 358 return 'contact-form-' + ip
374 } 359 }
diff --git a/server/lib/sync-channel.ts b/server/lib/sync-channel.ts
index 10167ee38..3a805a943 100644
--- a/server/lib/sync-channel.ts
+++ b/server/lib/sync-channel.ts
@@ -76,7 +76,7 @@ export async function synchronizeChannel (options: {
76 76
77 await JobQueue.Instance.createJobWithChildren(parent, children) 77 await JobQueue.Instance.createJobWithChildren(parent, children)
78 } catch (err) { 78 } catch (err) {
79 logger.error(`Failed to import channel ${channel.name}`, { err }) 79 logger.error(`Failed to import ${externalChannelUrl} in channel ${channel.name}`, { err })
80 channelSync.state = VideoChannelSyncState.FAILED 80 channelSync.state = VideoChannelSyncState.FAILED
81 await channelSync.save() 81 await channelSync.save()
82 } 82 }
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
index 02f160fe8..6eb865f7f 100644
--- a/server/lib/video-comment.ts
+++ b/server/lib/video-comment.ts
@@ -1,30 +1,41 @@
1import express from 'express'
1import { cloneDeep } from 'lodash' 2import { cloneDeep } from 'lodash'
2import * as Sequelize from 'sequelize' 3import * as Sequelize from 'sequelize'
3import express from 'express'
4import { logger } from '@server/helpers/logger' 4import { logger } from '@server/helpers/logger'
5import { sequelizeTypescript } from '@server/initializers/database' 5import { sequelizeTypescript } from '@server/initializers/database'
6import { ResultList } from '../../shared/models' 6import { ResultList } from '../../shared/models'
7import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model' 7import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
8import { VideoCommentModel } from '../models/video/video-comment' 8import { VideoCommentModel } from '../models/video/video-comment'
9import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models' 9import {
10 MAccountDefault,
11 MComment,
12 MCommentFormattable,
13 MCommentOwnerVideo,
14 MCommentOwnerVideoReply,
15 MVideoFullLight
16} from '../types/models'
10import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send' 17import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
11import { getLocalVideoCommentActivityPubUrl } from './activitypub/url' 18import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
12import { Hooks } from './plugins/hooks' 19import { Hooks } from './plugins/hooks'
13 20
14async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) { 21async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
15 const videoCommentInstanceBefore = cloneDeep(videoCommentInstance) 22 let videoCommentInstanceBefore: MCommentOwnerVideo
16 23
17 await sequelizeTypescript.transaction(async t => { 24 await sequelizeTypescript.transaction(async t => {
18 if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) { 25 const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
19 await sendDeleteVideoComment(videoCommentInstance, t) 26
27 videoCommentInstanceBefore = cloneDeep(comment)
28
29 if (comment.isOwned() || comment.Video.isOwned()) {
30 await sendDeleteVideoComment(comment, t)
20 } 31 }
21 32
22 videoCommentInstance.markAsDeleted() 33 comment.markAsDeleted()
23 34
24 await videoCommentInstance.save({ transaction: t }) 35 await comment.save({ transaction: t })
25 })
26 36
27 logger.info('Video comment %d deleted.', videoCommentInstance.id) 37 logger.info('Video comment %d deleted.', comment.id)
38 })
28 39
29 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res }) 40 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
30} 41}
@@ -64,7 +75,7 @@ async function createVideoComment (obj: {
64 return savedComment 75 return savedComment
65} 76}
66 77
67function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree { 78function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
68 // Comments are sorted by id ASC 79 // Comments are sorted by id ASC
69 const comments = resultList.data 80 const comments = resultList.data
70 81
diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts
index c43085d16..17aa29cdd 100644
--- a/server/lib/video-tokens-manager.ts
+++ b/server/lib/video-tokens-manager.ts
@@ -1,5 +1,7 @@
1import LRUCache from 'lru-cache' 1import LRUCache from 'lru-cache'
2import { LRU_CACHE } from '@server/initializers/constants' 2import { LRU_CACHE } from '@server/initializers/constants'
3import { MUserAccountUrl } from '@server/types/models'
4import { pick } from '@shared/core-utils'
3import { buildUUID } from '@shared/extra-utils' 5import { buildUUID } from '@shared/extra-utils'
4 6
5// --------------------------------------------------------------------------- 7// ---------------------------------------------------------------------------
@@ -10,19 +12,22 @@ class VideoTokensManager {
10 12
11 private static instance: VideoTokensManager 13 private static instance: VideoTokensManager
12 14
13 private readonly lruCache = new LRUCache<string, string>({ 15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({
14 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, 16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
15 ttl: LRU_CACHE.VIDEO_TOKENS.TTL 17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL
16 }) 18 })
17 19
18 private constructor () {} 20 private constructor () {}
19 21
20 create (videoUUID: string) { 22 create (options: {
23 user: MUserAccountUrl
24 videoUUID: string
25 }) {
21 const token = buildUUID() 26 const token = buildUUID()
22 27
23 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL) 28 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
24 29
25 this.lruCache.set(token, videoUUID) 30 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
26 31
27 return { token, expires } 32 return { token, expires }
28 } 33 }
@@ -34,7 +39,16 @@ class VideoTokensManager {
34 const value = this.lruCache.get(options.token) 39 const value = this.lruCache.get(options.token)
35 if (!value) return false 40 if (!value) return false
36 41
37 return value === options.videoUUID 42 return value.videoUUID === options.videoUUID
43 }
44
45 getUserFromToken (options: {
46 token: string
47 }) {
48 const value = this.lruCache.get(options.token)
49 if (!value) return undefined
50
51 return value.user
38 } 52 }
39 53
40 static get Instance () { 54 static get Instance () {
diff --git a/server/middlewares/sort.ts b/server/middlewares/sort.ts
index 458895898..77a532276 100644
--- a/server/middlewares/sort.ts
+++ b/server/middlewares/sort.ts
@@ -1,5 +1,4 @@
1import express from 'express' 1import express from 'express'
2import { SortType } from '../models/utils'
3 2
4const setDefaultSort = setDefaultSortFactory('-createdAt') 3const setDefaultSort = setDefaultSortFactory('-createdAt')
5const setDefaultVideosSort = setDefaultSortFactory('-publishedAt') 4const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
@@ -7,27 +6,7 @@ const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
7const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name') 6const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
8 7
9const setDefaultSearchSort = setDefaultSortFactory('-match') 8const setDefaultSearchSort = setDefaultSortFactory('-match')
10 9const setBlacklistSort = setDefaultSortFactory('-createdAt')
11function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
12 const newSort: SortType = { sortModel: undefined, sortValue: '' }
13
14 if (!req.query.sort) req.query.sort = '-createdAt'
15
16 // Set model we want to sort onto
17 if (req.query.sort === '-createdAt' || req.query.sort === 'createdAt' ||
18 req.query.sort === '-id' || req.query.sort === 'id') {
19 // If we want to sort onto the BlacklistedVideos relation, we won't specify it in the query parameter...
20 newSort.sortModel = undefined
21 } else {
22 newSort.sortModel = 'Video'
23 }
24
25 newSort.sortValue = req.query.sort
26
27 req.query.sort = newSort
28
29 return next()
30}
31 10
32// --------------------------------------------------------------------------- 11// ---------------------------------------------------------------------------
33 12
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index ebbfc0a0a..0033a32ff 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -180,18 +180,16 @@ async function checkCanAccessVideoStaticFiles (options: {
180 return checkCanSeeVideo(options) 180 return checkCanSeeVideo(options)
181 } 181 }
182 182
183 if (!video.hasPrivateStaticPath()) return true
184
185 const videoFileToken = req.query.videoFileToken 183 const videoFileToken = req.query.videoFileToken
186 if (!videoFileToken) { 184 if (videoFileToken && VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
187 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 185 const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
188 return false
189 }
190 186
191 if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) { 187 res.locals.videoFileToken = { user }
192 return true 188 return true
193 } 189 }
194 190
191 if (!video.hasPrivateStaticPath()) return true
192
195 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 193 res.sendStatus(HttpStatusCode.FORBIDDEN_403)
196 return false 194 return false
197} 195}
diff --git a/server/models/abuse/abuse-message.ts b/server/models/abuse/abuse-message.ts
index 20008768b..14a5bffa2 100644
--- a/server/models/abuse/abuse-message.ts
+++ b/server/models/abuse/abuse-message.ts
@@ -5,7 +5,7 @@ import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
5import { AbuseMessage } from '@shared/models' 5import { AbuseMessage } from '@shared/models'
6import { AttributesOnly } from '@shared/typescript-utils' 6import { AttributesOnly } from '@shared/typescript-utils'
7import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' 7import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
8import { getSort, throwIfNotValid } from '../utils' 8import { getSort, throwIfNotValid } from '../shared'
9import { AbuseModel } from './abuse' 9import { AbuseModel } from './abuse'
10 10
11@Table({ 11@Table({
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
index 4c6a96a86..4ce40bf2f 100644
--- a/server/models/abuse/abuse.ts
+++ b/server/models/abuse/abuse.ts
@@ -34,13 +34,13 @@ import { AttributesOnly } from '@shared/typescript-utils'
34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants' 34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models' 35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' 36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
37import { getSort, throwIfNotValid } from '../utils' 37import { getSort, throwIfNotValid } from '../shared'
38import { ThumbnailModel } from '../video/thumbnail' 38import { ThumbnailModel } from '../video/thumbnail'
39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video' 39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
40import { VideoBlacklistModel } from '../video/video-blacklist' 40import { VideoBlacklistModel } from '../video/video-blacklist'
41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel' 41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment' 42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder' 43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './sql/abuse-query-builder'
44import { VideoAbuseModel } from './video-abuse' 44import { VideoAbuseModel } from './video-abuse'
45import { VideoCommentAbuseModel } from './video-comment-abuse' 45import { VideoCommentAbuseModel } from './video-comment-abuse'
46 46
diff --git a/server/models/abuse/abuse-query-builder.ts b/server/models/abuse/sql/abuse-query-builder.ts
index 74f4542e5..282d4541a 100644
--- a/server/models/abuse/abuse-query-builder.ts
+++ b/server/models/abuse/sql/abuse-query-builder.ts
@@ -2,7 +2,7 @@
2import { exists } from '@server/helpers/custom-validators/misc' 2import { exists } from '@server/helpers/custom-validators/misc'
3import { forceNumber } from '@shared/core-utils' 3import { forceNumber } from '@shared/core-utils'
4import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models' 4import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
5import { buildBlockedAccountSQL, buildDirectionAndField } from '../utils' 5import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared'
6 6
7export type BuildAbusesQueryOptions = { 7export type BuildAbusesQueryOptions = {
8 start: number 8 start: number
@@ -157,7 +157,7 @@ function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' |
157} 157}
158 158
159function buildAbuseOrder (value: string) { 159function buildAbuseOrder (value: string) {
160 const { direction, field } = buildDirectionAndField(value) 160 const { direction, field } = buildSortDirectionAndField(value)
161 161
162 return `ORDER BY "abuse"."${field}" ${direction}` 162 return `ORDER BY "abuse"."${field}" ${direction}`
163} 163}
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index 377249b38..f6212ff6e 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -6,7 +6,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountBlock } from '../../../shared/models' 6import { AccountBlock } from '../../../shared/models'
7import { ActorModel } from '../actor/actor' 7import { ActorModel } from '../actor/actor'
8import { ServerModel } from '../server/server' 8import { ServerModel } from '../server/server'
9import { createSafeIn, getSort, searchAttribute } from '../utils' 9import { createSafeIn, getSort, searchAttribute } from '../shared'
10import { AccountModel } from './account' 10import { AccountModel } from './account'
11 11
12@Table({ 12@Table({
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index 7afc907da..9e7ef4394 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -11,7 +11,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
12import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants' 12import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
13import { ActorModel } from '../actor/actor' 13import { ActorModel } from '../actor/actor'
14import { getSort, throwIfNotValid } from '../utils' 14import { getSort, throwIfNotValid } from '../shared'
15import { VideoModel } from '../video/video' 15import { VideoModel } from '../video/video'
16import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel' 16import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
17import { AccountModel } from './account' 17import { AccountModel } from './account'
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 8a7dfba94..dc989417b 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -16,7 +16,7 @@ import {
16 Table, 16 Table,
17 UpdatedAt 17 UpdatedAt
18} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { ModelCache } from '@server/models/model-cache' 19import { ModelCache } from '@server/models/shared/model-cache'
20import { AttributesOnly } from '@shared/typescript-utils' 20import { AttributesOnly } from '@shared/typescript-utils'
21import { Account, AccountSummary } from '../../../shared/models/actors' 21import { Account, AccountSummary } from '../../../shared/models/actors'
22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' 22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
@@ -38,7 +38,7 @@ import { ApplicationModel } from '../application/application'
38import { ServerModel } from '../server/server' 38import { ServerModel } from '../server/server'
39import { ServerBlocklistModel } from '../server/server-blocklist' 39import { ServerBlocklistModel } from '../server/server-blocklist'
40import { UserModel } from '../user/user' 40import { UserModel } from '../user/user'
41import { getSort, throwIfNotValid } from '../utils' 41import { buildSQLAttributes, getSort, throwIfNotValid } from '../shared'
42import { VideoModel } from '../video/video' 42import { VideoModel } from '../video/video'
43import { VideoChannelModel } from '../video/video-channel' 43import { VideoChannelModel } from '../video/video-channel'
44import { VideoCommentModel } from '../video/video-comment' 44import { VideoCommentModel } from '../video/video-comment'
@@ -251,6 +251,18 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
251 return undefined 251 return undefined
252 } 252 }
253 253
254 // ---------------------------------------------------------------------------
255
256 static getSQLAttributes (tableName: string, aliasPrefix = '') {
257 return buildSQLAttributes({
258 model: this,
259 tableName,
260 aliasPrefix
261 })
262 }
263
264 // ---------------------------------------------------------------------------
265
254 static load (id: number, transaction?: Transaction): Promise<MAccountDefault> { 266 static load (id: number, transaction?: Transaction): Promise<MAccountDefault> {
255 return AccountModel.findByPk(id, { transaction }) 267 return AccountModel.findByPk(id, { transaction })
256 } 268 }
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
index 9615229dd..32e5d78b0 100644
--- a/server/models/actor/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -38,7 +38,7 @@ import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAM
38import { AccountModel } from '../account/account' 38import { AccountModel } from '../account/account'
39import { ServerModel } from '../server/server' 39import { ServerModel } from '../server/server'
40import { doesExist } from '../shared/query' 40import { doesExist } from '../shared/query'
41import { createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../utils' 41import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared'
42import { VideoChannelModel } from '../video/video-channel' 42import { VideoChannelModel } from '../video/video-channel'
43import { ActorModel, unusedActorAttributesForAPI } from './actor' 43import { ActorModel, unusedActorAttributesForAPI } from './actor'
44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder' 44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder'
@@ -140,6 +140,18 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
140 }) 140 })
141 } 141 }
142 142
143 // ---------------------------------------------------------------------------
144
145 static getSQLAttributes (tableName: string, aliasPrefix = '') {
146 return buildSQLAttributes({
147 model: this,
148 tableName,
149 aliasPrefix
150 })
151 }
152
153 // ---------------------------------------------------------------------------
154
143 /* 155 /*
144 * @deprecated Use `findOrCreateCustom` instead 156 * @deprecated Use `findOrCreateCustom` instead
145 */ 157 */
@@ -213,7 +225,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
213 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` + 225 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
214 `LIMIT 1` 226 `LIMIT 1`
215 227
216 return doesExist(query, { actorId, followerActorId }) 228 return doesExist(this.sequelize, query, { actorId, followerActorId })
217 } 229 }
218 230
219 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> { 231 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts
index f2b3b2f4b..9c34a0101 100644
--- a/server/models/actor/actor-image.ts
+++ b/server/models/actor/actor-image.ts
@@ -22,7 +22,7 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
22import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
23import { CONFIG } from '../../initializers/config' 23import { CONFIG } from '../../initializers/config'
24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants' 24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
25import { throwIfNotValid } from '../utils' 25import { buildSQLAttributes, throwIfNotValid } from '../shared'
26import { ActorModel } from './actor' 26import { ActorModel } from './actor'
27 27
28@Table({ 28@Table({
@@ -94,6 +94,18 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err })) 94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err }))
95 } 95 }
96 96
97 // ---------------------------------------------------------------------------
98
99 static getSQLAttributes (tableName: string, aliasPrefix = '') {
100 return buildSQLAttributes({
101 model: this,
102 tableName,
103 aliasPrefix
104 })
105 }
106
107 // ---------------------------------------------------------------------------
108
97 static loadByName (filename: string) { 109 static loadByName (filename: string) {
98 const query = { 110 const query = {
99 where: { 111 where: {
diff --git a/server/models/actor/actor.ts b/server/models/actor/actor.ts
index d7afa727d..1432e8757 100644
--- a/server/models/actor/actor.ts
+++ b/server/models/actor/actor.ts
@@ -17,7 +17,7 @@ import {
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { activityPubContextify } from '@server/lib/activitypub/context' 18import { activityPubContextify } from '@server/lib/activitypub/context'
19import { getBiggestActorImage } from '@server/lib/actor-image' 19import { getBiggestActorImage } from '@server/lib/actor-image'
20import { ModelCache } from '@server/models/model-cache' 20import { ModelCache } from '@server/models/shared/model-cache'
21import { forceNumber, getLowercaseExtension } from '@shared/core-utils' 21import { forceNumber, getLowercaseExtension } from '@shared/core-utils'
22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models' 22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
23import { AttributesOnly } from '@shared/typescript-utils' 23import { AttributesOnly } from '@shared/typescript-utils'
@@ -55,7 +55,7 @@ import {
55import { AccountModel } from '../account/account' 55import { AccountModel } from '../account/account'
56import { getServerActor } from '../application/application' 56import { getServerActor } from '../application/application'
57import { ServerModel } from '../server/server' 57import { ServerModel } from '../server/server'
58import { isOutdated, throwIfNotValid } from '../utils' 58import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared'
59import { VideoModel } from '../video/video' 59import { VideoModel } from '../video/video'
60import { VideoChannelModel } from '../video/video-channel' 60import { VideoChannelModel } from '../video/video-channel'
61import { ActorFollowModel } from './actor-follow' 61import { ActorFollowModel } from './actor-follow'
@@ -65,7 +65,7 @@ enum ScopeNames {
65 FULL = 'FULL' 65 FULL = 'FULL'
66} 66}
67 67
68export const unusedActorAttributesForAPI = [ 68export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] = [
69 'publicKey', 69 'publicKey',
70 'privateKey', 70 'privateKey',
71 'inboxUrl', 71 'inboxUrl',
@@ -306,6 +306,27 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
306 }) 306 })
307 VideoChannel: VideoChannelModel 307 VideoChannel: VideoChannelModel
308 308
309 // ---------------------------------------------------------------------------
310
311 static getSQLAttributes (tableName: string, aliasPrefix = '') {
312 return buildSQLAttributes({
313 model: this,
314 tableName,
315 aliasPrefix
316 })
317 }
318
319 static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
320 return buildSQLAttributes({
321 model: this,
322 tableName,
323 aliasPrefix,
324 excludeAttributes: unusedActorAttributesForAPI
325 })
326 }
327
328 // ---------------------------------------------------------------------------
329
309 static async load (id: number): Promise<MActor> { 330 static async load (id: number): Promise<MActor> {
310 const actorServer = await getServerActor() 331 const actorServer = await getServerActor()
311 if (id === actorServer.id) return actorServer 332 if (id === actorServer.id) return actorServer
diff --git a/server/models/actor/sql/instance-list-followers-query-builder.ts b/server/models/actor/sql/instance-list-followers-query-builder.ts
index 4a17a8f11..34ce29b5d 100644
--- a/server/models/actor/sql/instance-list-followers-query-builder.ts
+++ b/server/models/actor/sql/instance-list-followers-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared' 2import { ModelBuilder } from '@server/models/shared'
3import { parseRowCountResult } from '@server/models/utils'
4import { MActorFollowActorsDefault } from '@server/types/models' 3import { MActorFollowActorsDefault } from '@server/types/models'
5import { ActivityPubActorType, FollowState } from '@shared/models' 4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' 6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7 7
8export interface ListFollowersOptions { 8export interface ListFollowersOptions {
diff --git a/server/models/actor/sql/instance-list-following-query-builder.ts b/server/models/actor/sql/instance-list-following-query-builder.ts
index 880170b85..77b4e3dce 100644
--- a/server/models/actor/sql/instance-list-following-query-builder.ts
+++ b/server/models/actor/sql/instance-list-following-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared' 2import { ModelBuilder } from '@server/models/shared'
3import { parseRowCountResult } from '@server/models/utils'
4import { MActorFollowActorsDefault } from '@server/types/models' 3import { MActorFollowActorsDefault } from '@server/types/models'
5import { ActivityPubActorType, FollowState } from '@shared/models' 4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' 6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7 7
8export interface ListFollowingOptions { 8export interface ListFollowingOptions {
diff --git a/server/models/actor/sql/shared/actor-follow-table-attributes.ts b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
index 156b37d44..7dd908ece 100644
--- a/server/models/actor/sql/shared/actor-follow-table-attributes.ts
+++ b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
@@ -1,62 +1,31 @@
1import { logger } from '@server/helpers/logger'
2import { Memoize } from '@server/helpers/memoize'
3import { ServerModel } from '@server/models/server/server'
4import { ActorModel } from '../../actor'
5import { ActorFollowModel } from '../../actor-follow'
6import { ActorImageModel } from '../../actor-image'
7
1export class ActorFollowTableAttributes { 8export class ActorFollowTableAttributes {
2 9
10 @Memoize()
3 getFollowAttributes () { 11 getFollowAttributes () {
4 return [ 12 logger.error('coucou')
5 '"ActorFollowModel"."id"', 13
6 '"ActorFollowModel"."state"', 14 return ActorFollowModel.getSQLAttributes('ActorFollowModel').join(', ')
7 '"ActorFollowModel"."score"',
8 '"ActorFollowModel"."url"',
9 '"ActorFollowModel"."actorId"',
10 '"ActorFollowModel"."targetActorId"',
11 '"ActorFollowModel"."createdAt"',
12 '"ActorFollowModel"."updatedAt"'
13 ].join(', ')
14 } 15 }
15 16
17 @Memoize()
16 getActorAttributes (actorTableName: string) { 18 getActorAttributes (actorTableName: string) {
17 return [ 19 return ActorModel.getSQLAttributes(actorTableName, `${actorTableName}.`).join(', ')
18 `"${actorTableName}"."id" AS "${actorTableName}.id"`,
19 `"${actorTableName}"."type" AS "${actorTableName}.type"`,
20 `"${actorTableName}"."preferredUsername" AS "${actorTableName}.preferredUsername"`,
21 `"${actorTableName}"."url" AS "${actorTableName}.url"`,
22 `"${actorTableName}"."publicKey" AS "${actorTableName}.publicKey"`,
23 `"${actorTableName}"."privateKey" AS "${actorTableName}.privateKey"`,
24 `"${actorTableName}"."followersCount" AS "${actorTableName}.followersCount"`,
25 `"${actorTableName}"."followingCount" AS "${actorTableName}.followingCount"`,
26 `"${actorTableName}"."inboxUrl" AS "${actorTableName}.inboxUrl"`,
27 `"${actorTableName}"."outboxUrl" AS "${actorTableName}.outboxUrl"`,
28 `"${actorTableName}"."sharedInboxUrl" AS "${actorTableName}.sharedInboxUrl"`,
29 `"${actorTableName}"."followersUrl" AS "${actorTableName}.followersUrl"`,
30 `"${actorTableName}"."followingUrl" AS "${actorTableName}.followingUrl"`,
31 `"${actorTableName}"."remoteCreatedAt" AS "${actorTableName}.remoteCreatedAt"`,
32 `"${actorTableName}"."serverId" AS "${actorTableName}.serverId"`,
33 `"${actorTableName}"."createdAt" AS "${actorTableName}.createdAt"`,
34 `"${actorTableName}"."updatedAt" AS "${actorTableName}.updatedAt"`
35 ].join(', ')
36 } 20 }
37 21
22 @Memoize()
38 getServerAttributes (actorTableName: string) { 23 getServerAttributes (actorTableName: string) {
39 return [ 24 return ServerModel.getSQLAttributes(`${actorTableName}->Server`, `${actorTableName}.Server.`).join(', ')
40 `"${actorTableName}->Server"."id" AS "${actorTableName}.Server.id"`,
41 `"${actorTableName}->Server"."host" AS "${actorTableName}.Server.host"`,
42 `"${actorTableName}->Server"."redundancyAllowed" AS "${actorTableName}.Server.redundancyAllowed"`,
43 `"${actorTableName}->Server"."createdAt" AS "${actorTableName}.Server.createdAt"`,
44 `"${actorTableName}->Server"."updatedAt" AS "${actorTableName}.Server.updatedAt"`
45 ].join(', ')
46 } 25 }
47 26
27 @Memoize()
48 getAvatarAttributes (actorTableName: string) { 28 getAvatarAttributes (actorTableName: string) {
49 return [ 29 return ActorImageModel.getSQLAttributes(`${actorTableName}->Avatars`, `${actorTableName}.Avatars.`).join(', ')
50 `"${actorTableName}->Avatars"."id" AS "${actorTableName}.Avatars.id"`,
51 `"${actorTableName}->Avatars"."filename" AS "${actorTableName}.Avatars.filename"`,
52 `"${actorTableName}->Avatars"."height" AS "${actorTableName}.Avatars.height"`,
53 `"${actorTableName}->Avatars"."width" AS "${actorTableName}.Avatars.width"`,
54 `"${actorTableName}->Avatars"."fileUrl" AS "${actorTableName}.Avatars.fileUrl"`,
55 `"${actorTableName}->Avatars"."onDisk" AS "${actorTableName}.Avatars.onDisk"`,
56 `"${actorTableName}->Avatars"."type" AS "${actorTableName}.Avatars.type"`,
57 `"${actorTableName}->Avatars"."actorId" AS "${actorTableName}.Avatars.actorId"`,
58 `"${actorTableName}->Avatars"."createdAt" AS "${actorTableName}.Avatars.createdAt"`,
59 `"${actorTableName}->Avatars"."updatedAt" AS "${actorTableName}.Avatars.updatedAt"`
60 ].join(', ')
61 } 30 }
62} 31}
diff --git a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
index 1d70fbe70..d9593e48b 100644
--- a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
+++ b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
@@ -1,7 +1,7 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery } from '@server/models/shared' 2import { AbstractRunQuery } from '@server/models/shared'
3import { getInstanceFollowsSort } from '@server/models/utils'
4import { ActorImageType } from '@shared/models' 3import { ActorImageType } from '@shared/models'
4import { getInstanceFollowsSort } from '../../../shared'
5import { ActorFollowTableAttributes } from './actor-follow-table-attributes' 5import { ActorFollowTableAttributes } from './actor-follow-table-attributes'
6 6
7type BaseOptions = { 7type BaseOptions = {
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 15909d5f3..c2a72b71f 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -34,7 +34,7 @@ import { CONFIG } from '../../initializers/config'
34import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' 34import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
35import { ActorModel } from '../actor/actor' 35import { ActorModel } from '../actor/actor'
36import { ServerModel } from '../server/server' 36import { ServerModel } from '../server/server'
37import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' 37import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../shared'
38import { ScheduleVideoUpdateModel } from '../video/schedule-video-update' 38import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
39import { VideoModel } from '../video/video' 39import { VideoModel } from '../video/video'
40import { VideoChannelModel } from '../video/video-channel' 40import { VideoChannelModel } from '../video/video-channel'
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
index 71c205ffa..9948c9f7a 100644
--- a/server/models/server/plugin.ts
+++ b/server/models/server/plugin.ts
@@ -11,7 +11,7 @@ import {
11 isPluginStableVersionValid, 11 isPluginStableVersionValid,
12 isPluginTypeValid 12 isPluginTypeValid
13} from '../../helpers/custom-validators/plugins' 13} from '../../helpers/custom-validators/plugins'
14import { getSort, throwIfNotValid } from '../utils' 14import { getSort, throwIfNotValid } from '../shared'
15 15
16@DefaultScope(() => ({ 16@DefaultScope(() => ({
17 attributes: { 17 attributes: {
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 9752dfbc3..3d755fe4a 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -4,7 +4,7 @@ import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormat
4import { ServerBlock } from '@shared/models' 4import { ServerBlock } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils' 5import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
7import { createSafeIn, getSort, searchAttribute } from '../utils' 7import { createSafeIn, getSort, searchAttribute } from '../shared'
8import { ServerModel } from './server' 8import { ServerModel } from './server'
9 9
10enum ScopeNames { 10enum ScopeNames {
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
index ef42de090..a5e05f460 100644
--- a/server/models/server/server.ts
+++ b/server/models/server/server.ts
@@ -4,7 +4,7 @@ import { MServer, MServerFormattable } from '@server/types/models/server'
4import { AttributesOnly } from '@shared/typescript-utils' 4import { AttributesOnly } from '@shared/typescript-utils'
5import { isHostValid } from '../../helpers/custom-validators/servers' 5import { isHostValid } from '../../helpers/custom-validators/servers'
6import { ActorModel } from '../actor/actor' 6import { ActorModel } from '../actor/actor'
7import { throwIfNotValid } from '../utils' 7import { buildSQLAttributes, throwIfNotValid } from '../shared'
8import { ServerBlocklistModel } from './server-blocklist' 8import { ServerBlocklistModel } from './server-blocklist'
9 9
10@Table({ 10@Table({
@@ -52,6 +52,18 @@ export class ServerModel extends Model<Partial<AttributesOnly<ServerModel>>> {
52 }) 52 })
53 BlockedBy: ServerBlocklistModel[] 53 BlockedBy: ServerBlocklistModel[]
54 54
55 // ---------------------------------------------------------------------------
56
57 static getSQLAttributes (tableName: string, aliasPrefix = '') {
58 return buildSQLAttributes({
59 model: this,
60 tableName,
61 aliasPrefix
62 })
63 }
64
65 // ---------------------------------------------------------------------------
66
55 static load (id: number, transaction?: Transaction): Promise<MServer> { 67 static load (id: number, transaction?: Transaction): Promise<MServer> {
56 const query = { 68 const query = {
57 where: { 69 where: {
diff --git a/server/models/shared/index.ts b/server/models/shared/index.ts
index 04528929c..5a7621e4d 100644
--- a/server/models/shared/index.ts
+++ b/server/models/shared/index.ts
@@ -1,4 +1,8 @@
1export * from './abstract-run-query' 1export * from './abstract-run-query'
2export * from './model-builder' 2export * from './model-builder'
3export * from './model-cache'
3export * from './query' 4export * from './query'
5export * from './sequelize-helpers'
6export * from './sort'
7export * from './sql'
4export * from './update' 8export * from './update'
diff --git a/server/models/shared/model-builder.ts b/server/models/shared/model-builder.ts
index c015ca4f5..07f7c4038 100644
--- a/server/models/shared/model-builder.ts
+++ b/server/models/shared/model-builder.ts
@@ -1,7 +1,24 @@
1import { isPlainObject } from 'lodash' 1import { isPlainObject } from 'lodash'
2import { Model as SequelizeModel, Sequelize } from 'sequelize' 2import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
3import { logger } from '@server/helpers/logger' 3import { logger } from '@server/helpers/logger'
4 4
5/**
6 *
7 * Build Sequelize models from sequelize raw query (that must use { nest: true } options)
8 *
9 * In order to sequelize to correctly build the JSON this class will ingest,
10 * the columns selected in the raw query should be in the following form:
11 * * All tables must be Pascal Cased (for example "VideoChannel")
12 * * Root table must end with `Model` (for example "VideoCommentModel")
13 * * Joined tables must contain the origin table name + '->JoinedTable'. For example:
14 * * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
15 * * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
16 * * Selected columns must be renamed to contain the JSON path:
17 * * "videoComment"."id": "VideoCommentModel"."id"
18 * * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
19 * * All tables must contain the row id
20 */
21
5export class ModelBuilder <T extends SequelizeModel> { 22export class ModelBuilder <T extends SequelizeModel> {
6 private readonly modelRegistry = new Map<string, T>() 23 private readonly modelRegistry = new Map<string, T>()
7 24
@@ -72,18 +89,18 @@ export class ModelBuilder <T extends SequelizeModel> {
72 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName), 89 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
73 { existing: this.sequelize.modelManager.all.map(m => m.name) } 90 { existing: this.sequelize.modelManager.all.map(m => m.name) }
74 ) 91 )
75 return undefined 92 return { created: false, model: null }
76 } 93 }
77 94
78 // FIXME: typings 95 const model = Model.build(json, { raw: true, isNewRecord: false })
79 const model = new (Model as any)(json) 96
80 this.modelRegistry.set(registryKey, model) 97 this.modelRegistry.set(registryKey, model)
81 98
82 return { created: true, model } 99 return { created: true, model }
83 } 100 }
84 101
85 private findModelBuilder (modelName: string) { 102 private findModelBuilder (modelName: string) {
86 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) 103 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
87 } 104 }
88 105
89 private buildSequelizeModelName (modelName: string) { 106 private buildSequelizeModelName (modelName: string) {
diff --git a/server/models/model-cache.ts b/server/models/shared/model-cache.ts
index 3651267e7..3651267e7 100644
--- a/server/models/model-cache.ts
+++ b/server/models/shared/model-cache.ts
diff --git a/server/models/shared/query.ts b/server/models/shared/query.ts
index 036cc13c6..934acc21f 100644
--- a/server/models/shared/query.ts
+++ b/server/models/shared/query.ts
@@ -1,17 +1,82 @@
1import { BindOrReplacements, QueryTypes } from 'sequelize' 1import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database' 2import validator from 'validator'
3import { forceNumber } from '@shared/core-utils'
3 4
4function doesExist (query: string, bind?: BindOrReplacements) { 5function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) {
5 const options = { 6 const options = {
6 type: QueryTypes.SELECT as QueryTypes.SELECT, 7 type: QueryTypes.SELECT as QueryTypes.SELECT,
7 bind, 8 bind,
8 raw: true 9 raw: true
9 } 10 }
10 11
11 return sequelizeTypescript.query(query, options) 12 return sequelize.query(query, options)
12 .then(results => results.length === 1) 13 .then(results => results.length === 1)
13} 14}
14 15
16function createSimilarityAttribute (col: string, value: string) {
17 return Sequelize.fn(
18 'similarity',
19
20 searchTrigramNormalizeCol(col),
21
22 searchTrigramNormalizeValue(value)
23 )
24}
25
26function buildWhereIdOrUUID (id: number | string) {
27 return validator.isInt('' + id) ? { id } : { uuid: id }
28}
29
30function parseAggregateResult (result: any) {
31 if (!result) return 0
32
33 const total = forceNumber(result)
34 if (isNaN(total)) return 0
35
36 return total
37}
38
39function parseRowCountResult (result: any) {
40 if (result.length !== 0) return result[0].total
41
42 return 0
43}
44
45function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
46 return toEscape.map(t => {
47 return t === null
48 ? null
49 : sequelize.escape('' + t)
50 }).concat(additionalUnescaped).join(', ')
51}
52
53function searchAttribute (sourceField?: string, targetField?: string) {
54 if (!sourceField) return {}
55
56 return {
57 [targetField]: {
58 // FIXME: ts error
59 [Op.iLike as any]: `%${sourceField}%`
60 }
61 }
62}
63
15export { 64export {
16 doesExist 65 doesExist,
66 createSimilarityAttribute,
67 buildWhereIdOrUUID,
68 parseAggregateResult,
69 parseRowCountResult,
70 createSafeIn,
71 searchAttribute
72}
73
74// ---------------------------------------------------------------------------
75
76function searchTrigramNormalizeValue (value: string) {
77 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
78}
79
80function searchTrigramNormalizeCol (col: string) {
81 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
17} 82}
diff --git a/server/models/shared/sequelize-helpers.ts b/server/models/shared/sequelize-helpers.ts
new file mode 100644
index 000000000..7af8471dc
--- /dev/null
+++ b/server/models/shared/sequelize-helpers.ts
@@ -0,0 +1,39 @@
1import { Sequelize } from 'sequelize'
2
3function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
4 if (!model.createdAt || !model.updatedAt) {
5 throw new Error('Miss createdAt & updatedAt attributes to model')
6 }
7
8 const now = Date.now()
9 const createdAtTime = model.createdAt.getTime()
10 const updatedAtTime = model.updatedAt.getTime()
11
12 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
13}
14
15function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
16 if (nullable && (value === null || value === undefined)) return
17
18 if (validator(value) === false) {
19 throw new Error(`"${value}" is not a valid ${fieldName}.`)
20 }
21}
22
23function buildTrigramSearchIndex (indexName: string, attribute: string) {
24 return {
25 name: indexName,
26 // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
27 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
28 using: 'gin',
29 operator: 'gin_trgm_ops'
30 }
31}
32
33// ---------------------------------------------------------------------------
34
35export {
36 throwIfNotValid,
37 buildTrigramSearchIndex,
38 isOutdated
39}
diff --git a/server/models/shared/sort.ts b/server/models/shared/sort.ts
new file mode 100644
index 000000000..77e84dcf4
--- /dev/null
+++ b/server/models/shared/sort.ts
@@ -0,0 +1,160 @@
1import { literal, OrderItem, Sequelize } from 'sequelize'
2
3// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
4function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
5 const { direction, field } = buildSortDirectionAndField(value)
6
7 let finalField: string | ReturnType<typeof Sequelize.col>
8
9 if (field.toLowerCase() === 'match') { // Search
10 finalField = Sequelize.col('similarity')
11 } else {
12 finalField = field
13 }
14
15 return [ [ finalField, direction ], lastSort ]
16}
17
18function getAdminUsersSort (value: string): OrderItem[] {
19 const { direction, field } = buildSortDirectionAndField(value)
20
21 let finalField: string | ReturnType<typeof Sequelize.col>
22
23 if (field === 'videoQuotaUsed') { // Users list
24 finalField = Sequelize.col('videoQuotaUsed')
25 } else {
26 finalField = field
27 }
28
29 const nullPolicy = direction === 'ASC'
30 ? 'NULLS FIRST'
31 : 'NULLS LAST'
32
33 // FIXME: typings
34 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
35}
36
37function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
38 const { direction, field } = buildSortDirectionAndField(value)
39
40 if (field.toLowerCase() === 'name') {
41 return [ [ 'displayName', direction ], lastSort ]
42 }
43
44 return getSort(value, lastSort)
45}
46
47function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
48 const { direction, field } = buildSortDirectionAndField(value)
49
50 if (field === 'totalReplies') {
51 return [
52 [ Sequelize.literal('"totalReplies"'), direction ],
53 lastSort
54 ]
55 }
56
57 return getSort(value, lastSort)
58}
59
60function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
61 const { direction, field } = buildSortDirectionAndField(value)
62
63 if (field.toLowerCase() === 'trending') { // Sort by aggregation
64 return [
65 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
66
67 [ Sequelize.col('VideoModel.views'), direction ],
68
69 lastSort
70 ]
71 } else if (field === 'publishedAt') {
72 return [
73 [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
74
75 [ Sequelize.col('VideoModel.publishedAt'), direction ],
76
77 lastSort
78 ]
79 }
80
81 let finalField: string | ReturnType<typeof Sequelize.col>
82
83 // Alias
84 if (field.toLowerCase() === 'match') { // Search
85 finalField = Sequelize.col('similarity')
86 } else {
87 finalField = field
88 }
89
90 const firstSort: OrderItem = typeof finalField === 'string'
91 ? finalField.split('.').concat([ direction ]) as OrderItem
92 : [ finalField, direction ]
93
94 return [ firstSort, lastSort ]
95}
96
97function getBlacklistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
98 const { direction, field } = buildSortDirectionAndField(value)
99
100 const videoFields = new Set([ 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid' ])
101
102 if (videoFields.has(field)) {
103 return [
104 [ literal(`"Video.${field}" ${direction}`) ],
105 lastSort
106 ] as OrderItem[]
107 }
108
109 return getSort(value, lastSort)
110}
111
112function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
113 const { direction, field } = buildSortDirectionAndField(value)
114
115 if (field === 'redundancyAllowed') {
116 return [
117 [ 'ActorFollowing.Server.redundancyAllowed', direction ],
118 lastSort
119 ]
120 }
121
122 return getSort(value, lastSort)
123}
124
125function getChannelSyncSort (value: string): OrderItem[] {
126 const { direction, field } = buildSortDirectionAndField(value)
127 if (field.toLowerCase() === 'videochannel') {
128 return [
129 [ literal('"VideoChannel.name"'), direction ]
130 ]
131 }
132 return [ [ field, direction ] ]
133}
134
135function buildSortDirectionAndField (value: string) {
136 let field: string
137 let direction: 'ASC' | 'DESC'
138
139 if (value.substring(0, 1) === '-') {
140 direction = 'DESC'
141 field = value.substring(1)
142 } else {
143 direction = 'ASC'
144 field = value
145 }
146
147 return { direction, field }
148}
149
150export {
151 buildSortDirectionAndField,
152 getPlaylistSort,
153 getSort,
154 getCommentSort,
155 getAdminUsersSort,
156 getVideoSort,
157 getBlacklistSort,
158 getChannelSyncSort,
159 getInstanceFollowsSort
160}
diff --git a/server/models/shared/sql.ts b/server/models/shared/sql.ts
new file mode 100644
index 000000000..5aaeb49f0
--- /dev/null
+++ b/server/models/shared/sql.ts
@@ -0,0 +1,68 @@
1import { literal, Model, ModelStatic } from 'sequelize'
2import { forceNumber } from '@shared/core-utils'
3import { AttributesOnly } from '@shared/typescript-utils'
4
5function buildLocalAccountIdsIn () {
6 return literal(
7 '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
8 )
9}
10
11function buildLocalActorIdsIn () {
12 return literal(
13 '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
14 )
15}
16
17function buildBlockedAccountSQL (blockerIds: number[]) {
18 const blockerIdsString = blockerIds.join(', ')
19
20 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
21 ' UNION ' +
22 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
23 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
24 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
25}
26
27function buildServerIdsFollowedBy (actorId: any) {
28 const actorIdNumber = forceNumber(actorId)
29
30 return '(' +
31 'SELECT "actor"."serverId" FROM "actorFollow" ' +
32 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
33 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
34 ')'
35}
36
37function buildSQLAttributes<M extends Model> (options: {
38 model: ModelStatic<M>
39 tableName: string
40
41 excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[]
42 aliasPrefix?: string
43}) {
44 const { model, tableName, aliasPrefix, excludeAttributes } = options
45
46 const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[]
47
48 return attributes
49 .filter(a => {
50 if (!excludeAttributes) return true
51 if (excludeAttributes.includes(a)) return false
52
53 return true
54 })
55 .map(a => {
56 return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"`
57 })
58}
59
60// ---------------------------------------------------------------------------
61
62export {
63 buildSQLAttributes,
64 buildBlockedAccountSQL,
65 buildServerIdsFollowedBy,
66 buildLocalAccountIdsIn,
67 buildLocalActorIdsIn
68}
diff --git a/server/models/shared/update.ts b/server/models/shared/update.ts
index d338211e3..d02c4535d 100644
--- a/server/models/shared/update.ts
+++ b/server/models/shared/update.ts
@@ -1,9 +1,15 @@
1import { QueryTypes, Transaction } from 'sequelize' 1import { QueryTypes, Sequelize, Transaction } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database'
3 2
4// Sequelize always skip the update if we only update updatedAt field 3// Sequelize always skip the update if we only update updatedAt field
5function setAsUpdated (table: string, id: number, transaction?: Transaction) { 4function setAsUpdated (options: {
6 return sequelizeTypescript.query( 5 sequelize: Sequelize
6 table: string
7 id: number
8 transaction?: Transaction
9}) {
10 const { sequelize, table, id, transaction } = options
11
12 return sequelize.query(
7 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`, 13 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`,
8 { 14 {
9 replacements: { table, id, updatedAt: new Date() }, 15 replacements: { table, id, updatedAt: new Date() },
diff --git a/server/models/user/sql/user-notitication-list-query-builder.ts b/server/models/user/sql/user-notitication-list-query-builder.ts
index 31b4932bf..d11546df0 100644
--- a/server/models/user/sql/user-notitication-list-query-builder.ts
+++ b/server/models/user/sql/user-notitication-list-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared' 2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { getSort } from '@server/models/utils'
4import { UserNotificationModelForApi } from '@server/types/models' 3import { UserNotificationModelForApi } from '@server/types/models'
5import { ActorImageType } from '@shared/models' 4import { ActorImageType } from '@shared/models'
5import { getSort } from '../../shared'
6 6
7export interface ListNotificationsOptions { 7export interface ListNotificationsOptions {
8 userId: number 8 userId: number
diff --git a/server/models/user/user-notification-setting.ts b/server/models/user/user-notification-setting.ts
index 66e1d85b3..394494c0c 100644
--- a/server/models/user/user-notification-setting.ts
+++ b/server/models/user/user-notification-setting.ts
@@ -17,7 +17,7 @@ import { MNotificationSettingFormattable } from '@server/types/models'
17import { AttributesOnly } from '@shared/typescript-utils' 17import { AttributesOnly } from '@shared/typescript-utils'
18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model' 18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' 19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
20import { throwIfNotValid } from '../utils' 20import { throwIfNotValid } from '../shared'
21import { UserModel } from './user' 21import { UserModel } from './user'
22 22
23@Table({ 23@Table({
diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts
index d37fa5dc7..6e134158f 100644
--- a/server/models/user/user-notification.ts
+++ b/server/models/user/user-notification.ts
@@ -13,7 +13,7 @@ import { AccountModel } from '../account/account'
13import { ActorFollowModel } from '../actor/actor-follow' 13import { ActorFollowModel } from '../actor/actor-follow'
14import { ApplicationModel } from '../application/application' 14import { ApplicationModel } from '../application/application'
15import { PluginModel } from '../server/plugin' 15import { PluginModel } from '../server/plugin'
16import { throwIfNotValid } from '../utils' 16import { throwIfNotValid } from '../shared'
17import { VideoModel } from '../video/video' 17import { VideoModel } from '../video/video'
18import { VideoBlacklistModel } from '../video/video-blacklist' 18import { VideoBlacklistModel } from '../video/video-blacklist'
19import { VideoCommentModel } from '../video/video-comment' 19import { VideoCommentModel } from '../video/video-comment'
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 672728a2a..0932a367a 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -30,6 +30,7 @@ import {
30 MUserNotifSettingChannelDefault, 30 MUserNotifSettingChannelDefault,
31 MUserWithNotificationSetting 31 MUserWithNotificationSetting
32} from '@server/types/models' 32} from '@server/types/models'
33import { forceNumber } from '@shared/core-utils'
33import { AttributesOnly } from '@shared/typescript-utils' 34import { AttributesOnly } from '@shared/typescript-utils'
34import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users' 35import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
35import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models' 36import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models'
@@ -63,14 +64,13 @@ import { ActorModel } from '../actor/actor'
63import { ActorFollowModel } from '../actor/actor-follow' 64import { ActorFollowModel } from '../actor/actor-follow'
64import { ActorImageModel } from '../actor/actor-image' 65import { ActorImageModel } from '../actor/actor-image'
65import { OAuthTokenModel } from '../oauth/oauth-token' 66import { OAuthTokenModel } from '../oauth/oauth-token'
66import { getAdminUsersSort, throwIfNotValid } from '../utils' 67import { getAdminUsersSort, throwIfNotValid } from '../shared'
67import { VideoModel } from '../video/video' 68import { VideoModel } from '../video/video'
68import { VideoChannelModel } from '../video/video-channel' 69import { VideoChannelModel } from '../video/video-channel'
69import { VideoImportModel } from '../video/video-import' 70import { VideoImportModel } from '../video/video-import'
70import { VideoLiveModel } from '../video/video-live' 71import { VideoLiveModel } from '../video/video-live'
71import { VideoPlaylistModel } from '../video/video-playlist' 72import { VideoPlaylistModel } from '../video/video-playlist'
72import { UserNotificationSettingModel } from './user-notification-setting' 73import { UserNotificationSettingModel } from './user-notification-setting'
73import { forceNumber } from '@shared/core-utils'
74 74
75enum ScopeNames { 75enum ScopeNames {
76 FOR_ME_API = 'FOR_ME_API', 76 FOR_ME_API = 'FOR_ME_API',
diff --git a/server/models/utils.ts b/server/models/utils.ts
deleted file mode 100644
index 3476799ce..000000000
--- a/server/models/utils.ts
+++ /dev/null
@@ -1,317 +0,0 @@
1import { literal, Op, OrderItem, Sequelize } from 'sequelize'
2import validator from 'validator'
3import { forceNumber } from '@shared/core-utils'
4
5type SortType = { sortModel: string, sortValue: string }
6
7// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
8function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
9 const { direction, field } = buildDirectionAndField(value)
10
11 let finalField: string | ReturnType<typeof Sequelize.col>
12
13 if (field.toLowerCase() === 'match') { // Search
14 finalField = Sequelize.col('similarity')
15 } else {
16 finalField = field
17 }
18
19 return [ [ finalField, direction ], lastSort ]
20}
21
22function getAdminUsersSort (value: string): OrderItem[] {
23 const { direction, field } = buildDirectionAndField(value)
24
25 let finalField: string | ReturnType<typeof Sequelize.col>
26
27 if (field === 'videoQuotaUsed') { // Users list
28 finalField = Sequelize.col('videoQuotaUsed')
29 } else {
30 finalField = field
31 }
32
33 const nullPolicy = direction === 'ASC'
34 ? 'NULLS FIRST'
35 : 'NULLS LAST'
36
37 // FIXME: typings
38 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
39}
40
41function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
42 const { direction, field } = buildDirectionAndField(value)
43
44 if (field.toLowerCase() === 'name') {
45 return [ [ 'displayName', direction ], lastSort ]
46 }
47
48 return getSort(value, lastSort)
49}
50
51function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
52 const { direction, field } = buildDirectionAndField(value)
53
54 if (field === 'totalReplies') {
55 return [
56 [ Sequelize.literal('"totalReplies"'), direction ],
57 lastSort
58 ]
59 }
60
61 return getSort(value, lastSort)
62}
63
64function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
65 const { direction, field } = buildDirectionAndField(value)
66
67 if (field.toLowerCase() === 'trending') { // Sort by aggregation
68 return [
69 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
70
71 [ Sequelize.col('VideoModel.views'), direction ],
72
73 lastSort
74 ]
75 } else if (field === 'publishedAt') {
76 return [
77 [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
78
79 [ Sequelize.col('VideoModel.publishedAt'), direction ],
80
81 lastSort
82 ]
83 }
84
85 let finalField: string | ReturnType<typeof Sequelize.col>
86
87 // Alias
88 if (field.toLowerCase() === 'match') { // Search
89 finalField = Sequelize.col('similarity')
90 } else {
91 finalField = field
92 }
93
94 const firstSort: OrderItem = typeof finalField === 'string'
95 ? finalField.split('.').concat([ direction ]) as OrderItem
96 : [ finalField, direction ]
97
98 return [ firstSort, lastSort ]
99}
100
101function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
102 const [ firstSort ] = getSort(value)
103
104 if (model) return [ [ literal(`"${model}.${firstSort[0]}" ${firstSort[1]}`) ], lastSort ] as OrderItem[]
105 return [ firstSort, lastSort ]
106}
107
108function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
109 const { direction, field } = buildDirectionAndField(value)
110
111 if (field === 'redundancyAllowed') {
112 return [
113 [ 'ActorFollowing.Server.redundancyAllowed', direction ],
114 lastSort
115 ]
116 }
117
118 return getSort(value, lastSort)
119}
120
121function getChannelSyncSort (value: string): OrderItem[] {
122 const { direction, field } = buildDirectionAndField(value)
123 if (field.toLowerCase() === 'videochannel') {
124 return [
125 [ literal('"VideoChannel.name"'), direction ]
126 ]
127 }
128 return [ [ field, direction ] ]
129}
130
131function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
132 if (!model.createdAt || !model.updatedAt) {
133 throw new Error('Miss createdAt & updatedAt attributes to model')
134 }
135
136 const now = Date.now()
137 const createdAtTime = model.createdAt.getTime()
138 const updatedAtTime = model.updatedAt.getTime()
139
140 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
141}
142
143function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
144 if (nullable && (value === null || value === undefined)) return
145
146 if (validator(value) === false) {
147 throw new Error(`"${value}" is not a valid ${fieldName}.`)
148 }
149}
150
151function buildTrigramSearchIndex (indexName: string, attribute: string) {
152 return {
153 name: indexName,
154 // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
155 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
156 using: 'gin',
157 operator: 'gin_trgm_ops'
158 }
159}
160
161function createSimilarityAttribute (col: string, value: string) {
162 return Sequelize.fn(
163 'similarity',
164
165 searchTrigramNormalizeCol(col),
166
167 searchTrigramNormalizeValue(value)
168 )
169}
170
171function buildBlockedAccountSQL (blockerIds: number[]) {
172 const blockerIdsString = blockerIds.join(', ')
173
174 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
175 ' UNION ' +
176 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
177 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
178 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
179}
180
181function buildBlockedAccountSQLOptimized (columnNameJoin: string, blockerIds: number[]) {
182 const blockerIdsString = blockerIds.join(', ')
183
184 return [
185 literal(
186 `NOT EXISTS (` +
187 ` SELECT 1 FROM "accountBlocklist" ` +
188 ` WHERE "targetAccountId" = ${columnNameJoin} ` +
189 ` AND "accountId" IN (${blockerIdsString})` +
190 `)`
191 ),
192
193 literal(
194 `NOT EXISTS (` +
195 ` SELECT 1 FROM "account" ` +
196 ` INNER JOIN "actor" ON account."actorId" = actor.id ` +
197 ` INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
198 ` WHERE "account"."id" = ${columnNameJoin} ` +
199 ` AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
200 `)`
201 )
202 ]
203}
204
205function buildServerIdsFollowedBy (actorId: any) {
206 const actorIdNumber = forceNumber(actorId)
207
208 return '(' +
209 'SELECT "actor"."serverId" FROM "actorFollow" ' +
210 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
211 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
212 ')'
213}
214
215function buildWhereIdOrUUID (id: number | string) {
216 return validator.isInt('' + id) ? { id } : { uuid: id }
217}
218
219function parseAggregateResult (result: any) {
220 if (!result) return 0
221
222 const total = forceNumber(result)
223 if (isNaN(total)) return 0
224
225 return total
226}
227
228function parseRowCountResult (result: any) {
229 if (result.length !== 0) return result[0].total
230
231 return 0
232}
233
234function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
235 return stringArr.map(t => {
236 return t === null
237 ? null
238 : sequelize.escape('' + t)
239 }).join(', ')
240}
241
242function buildLocalAccountIdsIn () {
243 return literal(
244 '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
245 )
246}
247
248function buildLocalActorIdsIn () {
249 return literal(
250 '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
251 )
252}
253
254function buildDirectionAndField (value: string) {
255 let field: string
256 let direction: 'ASC' | 'DESC'
257
258 if (value.substring(0, 1) === '-') {
259 direction = 'DESC'
260 field = value.substring(1)
261 } else {
262 direction = 'ASC'
263 field = value
264 }
265
266 return { direction, field }
267}
268
269function searchAttribute (sourceField?: string, targetField?: string) {
270 if (!sourceField) return {}
271
272 return {
273 [targetField]: {
274 // FIXME: ts error
275 [Op.iLike as any]: `%${sourceField}%`
276 }
277 }
278}
279
280// ---------------------------------------------------------------------------
281
282export {
283 buildBlockedAccountSQL,
284 buildBlockedAccountSQLOptimized,
285 buildLocalActorIdsIn,
286 getPlaylistSort,
287 SortType,
288 buildLocalAccountIdsIn,
289 getSort,
290 getCommentSort,
291 getAdminUsersSort,
292 getVideoSort,
293 getBlacklistSort,
294 getChannelSyncSort,
295 createSimilarityAttribute,
296 throwIfNotValid,
297 buildServerIdsFollowedBy,
298 buildTrigramSearchIndex,
299 buildWhereIdOrUUID,
300 isOutdated,
301 parseAggregateResult,
302 getInstanceFollowsSort,
303 buildDirectionAndField,
304 createSafeIn,
305 searchAttribute,
306 parseRowCountResult
307}
308
309// ---------------------------------------------------------------------------
310
311function searchTrigramNormalizeValue (value: string) {
312 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
313}
314
315function searchTrigramNormalizeCol (col: string) {
316 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
317}
diff --git a/server/models/video/sql/comment/video-comment-list-query-builder.ts b/server/models/video/sql/comment/video-comment-list-query-builder.ts
new file mode 100644
index 000000000..3960f6b13
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-list-query-builder.ts
@@ -0,0 +1,385 @@
1import { Model, Sequelize, Transaction } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { ActorImageType, VideoPrivacy } from '@shared/models'
4import { createSafeIn, getCommentSort, parseRowCountResult } from '../../../shared'
5import { VideoCommentTableAttributes } from './video-comment-table-attributes'
6
7export interface ListVideoCommentsOptions {
8 selectType: 'api' | 'feed' | 'comment-only'
9
10 start?: number
11 count?: number
12 sort?: string
13
14 videoId?: number
15 threadId?: number
16 accountId?: number
17 videoChannelId?: number
18
19 blockerAccountIds?: number[]
20
21 isThread?: boolean
22 notDeleted?: boolean
23 isLocal?: boolean
24 onLocalVideo?: boolean
25 onPublicVideo?: boolean
26 videoAccountOwnerId?: boolean
27
28 search?: string
29 searchAccount?: string
30 searchVideo?: string
31
32 includeReplyCounters?: boolean
33
34 transaction?: Transaction
35}
36
37export class VideoCommentListQueryBuilder extends AbstractRunQuery {
38 private readonly tableAttributes = new VideoCommentTableAttributes()
39
40 private innerQuery: string
41
42 private select = ''
43 private joins = ''
44
45 private innerSelect = ''
46 private innerJoins = ''
47 private innerWhere = ''
48
49 private readonly built = {
50 cte: false,
51 accountJoin: false,
52 videoJoin: false,
53 videoChannelJoin: false,
54 avatarJoin: false
55 }
56
57 constructor (
58 protected readonly sequelize: Sequelize,
59 private readonly options: ListVideoCommentsOptions
60 ) {
61 super(sequelize)
62 }
63
64 async listComments <T extends Model> () {
65 this.buildListQuery()
66
67 const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
68 const modelBuilder = new ModelBuilder<T>(this.sequelize)
69
70 return modelBuilder.createModels(results, 'VideoComment')
71 }
72
73 async countComments () {
74 this.buildCountQuery()
75
76 const result = await this.runQuery({ transaction: this.options.transaction })
77
78 return parseRowCountResult(result)
79 }
80
81 // ---------------------------------------------------------------------------
82
83 private buildListQuery () {
84 this.buildInnerListQuery()
85 this.buildListSelect()
86
87 this.query = `${this.select} ` +
88 `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
89 `${this.joins} ` +
90 `${this.getOrder()}`
91 }
92
93 private buildInnerListQuery () {
94 this.buildWhere()
95 this.buildInnerListSelect()
96
97 this.innerQuery = `${this.innerSelect} ` +
98 `FROM "videoComment" AS "VideoCommentModel" ` +
99 `${this.innerJoins} ` +
100 `${this.innerWhere} ` +
101 `${this.getOrder()} ` +
102 `${this.getInnerLimit()}`
103 }
104
105 // ---------------------------------------------------------------------------
106
107 private buildCountQuery () {
108 this.buildWhere()
109
110 this.query = `SELECT COUNT(*) AS "total" ` +
111 `FROM "videoComment" AS "VideoCommentModel" ` +
112 `${this.innerJoins} ` +
113 `${this.innerWhere}`
114 }
115
116 // ---------------------------------------------------------------------------
117
118 private buildWhere () {
119 let where: string[] = []
120
121 if (this.options.videoId) {
122 this.replacements.videoId = this.options.videoId
123
124 where.push('"VideoCommentModel"."videoId" = :videoId')
125 }
126
127 if (this.options.threadId) {
128 this.replacements.threadId = this.options.threadId
129
130 where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
131 }
132
133 if (this.options.accountId) {
134 this.replacements.accountId = this.options.accountId
135
136 where.push('"VideoCommentModel"."accountId" = :accountId')
137 }
138
139 if (this.options.videoChannelId) {
140 this.buildVideoChannelJoin()
141
142 this.replacements.videoChannelId = this.options.videoChannelId
143
144 where.push('"Account->VideoChannel"."id" = :videoChannelId')
145 }
146
147 if (this.options.blockerAccountIds) {
148 this.buildVideoChannelJoin()
149
150 where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
151 }
152
153 if (this.options.isThread === true) {
154 where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
155 }
156
157 if (this.options.notDeleted === true) {
158 where.push('"VideoCommentModel"."deletedAt" IS NULL')
159 }
160
161 if (this.options.isLocal === true) {
162 this.buildAccountJoin()
163
164 where.push('"Account->Actor"."serverId" IS NULL')
165 } else if (this.options.isLocal === false) {
166 this.buildAccountJoin()
167
168 where.push('"Account->Actor"."serverId" IS NOT NULL')
169 }
170
171 if (this.options.onLocalVideo === true) {
172 this.buildVideoJoin()
173
174 where.push('"Video"."remote" IS FALSE')
175 } else if (this.options.onLocalVideo === false) {
176 this.buildVideoJoin()
177
178 where.push('"Video"."remote" IS TRUE')
179 }
180
181 if (this.options.onPublicVideo === true) {
182 this.buildVideoJoin()
183
184 where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
185 }
186
187 if (this.options.videoAccountOwnerId) {
188 this.buildVideoChannelJoin()
189
190 this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
191
192 where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
193 }
194
195 if (this.options.search) {
196 this.buildVideoJoin()
197 this.buildAccountJoin()
198
199 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
200
201 where.push(
202 `(` +
203 `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
204 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
205 `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
206 `"Video"."name" ILIKE ${escapedLikeSearch} ` +
207 `)`
208 )
209 }
210
211 if (this.options.searchAccount) {
212 this.buildAccountJoin()
213
214 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
215
216 where.push(
217 `(` +
218 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
219 `"Account"."name" ILIKE ${escapedLikeSearch} ` +
220 `)`
221 )
222 }
223
224 if (this.options.searchVideo) {
225 this.buildVideoJoin()
226
227 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
228
229 where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
230 }
231
232 if (where.length !== 0) {
233 this.innerWhere = `WHERE ${where.join(' AND ')}`
234 }
235 }
236
237 private buildAccountJoin () {
238 if (this.built.accountJoin) return
239
240 this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
241 'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
242 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
243
244 this.built.accountJoin = true
245 }
246
247 private buildVideoJoin () {
248 if (this.built.videoJoin) return
249
250 this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
251
252 this.built.videoJoin = true
253 }
254
255 private buildVideoChannelJoin () {
256 if (this.built.videoChannelJoin) return
257
258 this.buildVideoJoin()
259
260 this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
261
262 this.built.videoChannelJoin = true
263 }
264
265 private buildAvatarsJoin () {
266 if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
267 if (this.built.avatarJoin) return
268
269 this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
270 `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
271 `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
272
273 this.built.avatarJoin = true
274 }
275
276 // ---------------------------------------------------------------------------
277
278 private buildListSelect () {
279 const toSelect = [ '"VideoCommentModel".*' ]
280
281 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
282 this.buildAvatarsJoin()
283
284 toSelect.push(this.tableAttributes.getAvatarAttributes())
285 }
286
287 if (this.options.includeReplyCounters === true) {
288 toSelect.push(this.getTotalRepliesSelect())
289 toSelect.push(this.getAuthorTotalRepliesSelect())
290 }
291
292 this.select = this.buildSelect(toSelect)
293 }
294
295 private buildInnerListSelect () {
296 let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
297
298 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
299 this.buildAccountJoin()
300 this.buildVideoJoin()
301
302 toSelect = toSelect.concat([
303 this.tableAttributes.getVideoAttributes(),
304 this.tableAttributes.getAccountAttributes(),
305 this.tableAttributes.getActorAttributes(),
306 this.tableAttributes.getServerAttributes()
307 ])
308 }
309
310 this.innerSelect = this.buildSelect(toSelect)
311 }
312
313 // ---------------------------------------------------------------------------
314
315 private getBlockWhere (commentTableName: string, channelTableName: string) {
316 const where: string[] = []
317
318 const blockerIdsString = createSafeIn(
319 this.sequelize,
320 this.options.blockerAccountIds,
321 [ `"${channelTableName}"."accountId"` ]
322 )
323
324 where.push(
325 `NOT EXISTS (` +
326 `SELECT 1 FROM "accountBlocklist" ` +
327 `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
328 `AND "accountId" IN (${blockerIdsString})` +
329 `)`
330 )
331
332 where.push(
333 `NOT EXISTS (` +
334 `SELECT 1 FROM "account" ` +
335 `INNER JOIN "actor" ON account."actorId" = actor.id ` +
336 `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
337 `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
338 `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
339 `)`
340 )
341
342 return where
343 }
344
345 // ---------------------------------------------------------------------------
346
347 private getTotalRepliesSelect () {
348 const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
349
350 return `(` +
351 `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
352 `LEFT JOIN "video" ON "video"."id" = "replies"."videoId" ` +
353 `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
354 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
355 `AND "deletedAt" IS NULL ` +
356 `AND ${blockWhereString} ` +
357 `) AS "totalReplies"`
358 }
359
360 private getAuthorTotalRepliesSelect () {
361 return `(` +
362 `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
363 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" ` +
364 `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
365 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
366 `) AS "totalRepliesFromVideoAuthor"`
367 }
368
369 private getOrder () {
370 if (!this.options.sort) return ''
371
372 const orders = getCommentSort(this.options.sort)
373
374 return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
375 }
376
377 private getInnerLimit () {
378 if (!this.options.count) return ''
379
380 this.replacements.limit = this.options.count
381 this.replacements.offset = this.options.start || 0
382
383 return `LIMIT :limit OFFSET :offset `
384 }
385}
diff --git a/server/models/video/sql/comment/video-comment-table-attributes.ts b/server/models/video/sql/comment/video-comment-table-attributes.ts
new file mode 100644
index 000000000..87f8750c1
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-table-attributes.ts
@@ -0,0 +1,43 @@
1import { Memoize } from '@server/helpers/memoize'
2import { AccountModel } from '@server/models/account/account'
3import { ActorModel } from '@server/models/actor/actor'
4import { ActorImageModel } from '@server/models/actor/actor-image'
5import { ServerModel } from '@server/models/server/server'
6import { VideoCommentModel } from '../../video-comment'
7
8export class VideoCommentTableAttributes {
9
10 @Memoize()
11 getVideoCommentAttributes () {
12 return VideoCommentModel.getSQLAttributes('VideoCommentModel').join(', ')
13 }
14
15 @Memoize()
16 getAccountAttributes () {
17 return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
18 }
19
20 @Memoize()
21 getVideoAttributes () {
22 return [
23 `"Video"."id" AS "Video.id"`,
24 `"Video"."uuid" AS "Video.uuid"`,
25 `"Video"."name" AS "Video.name"`
26 ].join(', ')
27 }
28
29 @Memoize()
30 getActorAttributes () {
31 return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
32 }
33
34 @Memoize()
35 getServerAttributes () {
36 return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
37 }
38
39 @Memoize()
40 getAvatarAttributes () {
41 return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
42 }
43}
diff --git a/server/models/video/sql/video/shared/abstract-video-query-builder.ts b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
index f0ce69501..cbd57ad8c 100644
--- a/server/models/video/sql/video/shared/abstract-video-query-builder.ts
+++ b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
@@ -1,9 +1,9 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import validator from 'validator' 2import validator from 'validator'
3import { createSafeIn } from '@server/models/utils'
4import { MUserAccountId } from '@server/types/models' 3import { MUserAccountId } from '@server/types/models'
5import { ActorImageType } from '@shared/models' 4import { ActorImageType } from '@shared/models'
6import { AbstractRunQuery } from '../../../../shared/abstract-run-query' 5import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
6import { createSafeIn } from '../../../../shared'
7import { VideoTableAttributes } from './video-table-attributes' 7import { VideoTableAttributes } from './video-table-attributes'
8 8
9/** 9/**
diff --git a/server/models/video/sql/video/videos-id-list-query-builder.ts b/server/models/video/sql/video/videos-id-list-query-builder.ts
index 7c864bf27..62f1855c7 100644
--- a/server/models/video/sql/video/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-id-list-query-builder.ts
@@ -2,11 +2,12 @@ import { Sequelize, Transaction } from 'sequelize'
2import validator from 'validator' 2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc' 3import { exists } from '@server/helpers/custom-validators/misc'
4import { WEBSERVER } from '@server/initializers/constants' 4import { WEBSERVER } from '@server/initializers/constants'
5import { buildDirectionAndField, createSafeIn, parseRowCountResult } from '@server/models/utils' 5import { buildSortDirectionAndField } from '@server/models/shared'
6import { MUserAccountId, MUserId } from '@server/types/models' 6import { MUserAccountId, MUserId } from '@server/types/models'
7import { forceNumber } from '@shared/core-utils'
7import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' 8import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
9import { createSafeIn, parseRowCountResult } from '../../../shared'
8import { AbstractRunQuery } from '../../../shared/abstract-run-query' 10import { AbstractRunQuery } from '../../../shared/abstract-run-query'
9import { forceNumber } from '@shared/core-utils'
10 11
11/** 12/**
12 * 13 *
@@ -665,7 +666,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
665 } 666 }
666 667
667 private buildOrder (value: string) { 668 private buildOrder (value: string) {
668 const { direction, field } = buildDirectionAndField(value) 669 const { direction, field } = buildSortDirectionAndField(value)
669 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field) 670 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
670 671
671 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()' 672 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index 653b9694b..cebde3755 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -4,7 +4,7 @@ import { MTag } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils' 4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoPrivacy, VideoState } from '../../../shared/models/videos' 5import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
6import { isVideoTagValid } from '../../helpers/custom-validators/videos' 6import { isVideoTagValid } from '../../helpers/custom-validators/videos'
7import { throwIfNotValid } from '../utils' 7import { throwIfNotValid } from '../shared'
8import { VideoModel } from './video' 8import { VideoModel } from './video'
9import { VideoTagModel } from './video-tag' 9import { VideoTagModel } from './video-tag'
10 10
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 1cd8224c0..9247d0e2b 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -5,7 +5,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos' 5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' 6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
7import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
8import { getBlacklistSort, searchAttribute, SortType, throwIfNotValid } from '../utils' 8import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared'
9import { ThumbnailModel } from './thumbnail' 9import { ThumbnailModel } from './thumbnail'
10import { VideoModel } from './video' 10import { VideoModel } from './video'
11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' 11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
@@ -57,7 +57,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
57 static listForApi (parameters: { 57 static listForApi (parameters: {
58 start: number 58 start: number
59 count: number 59 count: number
60 sort: SortType 60 sort: string
61 search?: string 61 search?: string
62 type?: VideoBlacklistType 62 type?: VideoBlacklistType
63 }) { 63 }) {
@@ -67,7 +67,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
67 return { 67 return {
68 offset: start, 68 offset: start,
69 limit: count, 69 limit: count,
70 order: getBlacklistSort(sort.sortModel, sort.sortValue) 70 order: getBlacklistSort(sort)
71 } 71 }
72 } 72 }
73 73
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index 5fbcd6e3b..2eaa77407 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -23,7 +23,7 @@ import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/vid
23import { logger } from '../../helpers/logger' 23import { logger } from '../../helpers/logger'
24import { CONFIG } from '../../initializers/config' 24import { CONFIG } from '../../initializers/config'
25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' 25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
26import { buildWhereIdOrUUID, throwIfNotValid } from '../utils' 26import { buildWhereIdOrUUID, throwIfNotValid } from '../shared'
27import { VideoModel } from './video' 27import { VideoModel } from './video'
28 28
29export enum ScopeNames { 29export enum ScopeNames {
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
index 1a1b8c88d..2db4b523a 100644
--- a/server/models/video/video-change-ownership.ts
+++ b/server/models/video/video-change-ownership.ts
@@ -3,7 +3,7 @@ import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@se
3import { AttributesOnly } from '@shared/typescript-utils' 3import { AttributesOnly } from '@shared/typescript-utils'
4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' 4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
5import { AccountModel } from '../account/account' 5import { AccountModel } from '../account/account'
6import { getSort } from '../utils' 6import { getSort } from '../shared'
7import { ScopeNames as VideoScopeNames, VideoModel } from './video' 7import { ScopeNames as VideoScopeNames, VideoModel } from './video'
8 8
9enum ScopeNames { 9enum ScopeNames {
diff --git a/server/models/video/video-channel-sync.ts b/server/models/video/video-channel-sync.ts
index 6e49cde10..a4cbf51f5 100644
--- a/server/models/video/video-channel-sync.ts
+++ b/server/models/video/video-channel-sync.ts
@@ -21,7 +21,7 @@ import { VideoChannelSync, VideoChannelSyncState } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 21import { AttributesOnly } from '@shared/typescript-utils'
22import { AccountModel } from '../account/account' 22import { AccountModel } from '../account/account'
23import { UserModel } from '../user/user' 23import { UserModel } from '../user/user'
24import { getChannelSyncSort, throwIfNotValid } from '../utils' 24import { getChannelSyncSort, throwIfNotValid } from '../shared'
25import { VideoChannelModel } from './video-channel' 25import { VideoChannelModel } from './video-channel'
26 26
27@DefaultScope(() => ({ 27@DefaultScope(() => ({
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 132c8f021..b71f5a197 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -43,8 +43,14 @@ import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
43import { ActorFollowModel } from '../actor/actor-follow' 43import { ActorFollowModel } from '../actor/actor-follow'
44import { ActorImageModel } from '../actor/actor-image' 44import { ActorImageModel } from '../actor/actor-image'
45import { ServerModel } from '../server/server' 45import { ServerModel } from '../server/server'
46import { setAsUpdated } from '../shared' 46import {
47import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 47 buildServerIdsFollowedBy,
48 buildTrigramSearchIndex,
49 createSimilarityAttribute,
50 getSort,
51 setAsUpdated,
52 throwIfNotValid
53} from '../shared'
48import { VideoModel } from './video' 54import { VideoModel } from './video'
49import { VideoPlaylistModel } from './video-playlist' 55import { VideoPlaylistModel } from './video-playlist'
50 56
@@ -831,6 +837,6 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
831 } 837 }
832 838
833 setAsUpdated (transaction?: Transaction) { 839 setAsUpdated (transaction?: Transaction) {
834 return setAsUpdated('videoChannel', this.id, transaction) 840 return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
835 } 841 }
836} 842}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index af9614d30..ff5142809 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,4 +1,4 @@
1import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 1import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
2import { 2import {
3 AllowNull, 3 AllowNull,
4 BelongsTo, 4 BelongsTo,
@@ -13,11 +13,9 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { exists } from '@server/helpers/custom-validators/misc'
17import { getServerActor } from '@server/models/application/application' 16import { getServerActor } from '@server/models/application/application'
18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' 17import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
19import { uniqify } from '@shared/core-utils' 18import { pick, uniqify } from '@shared/core-utils'
20import { VideoPrivacy } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 19import { AttributesOnly } from '@shared/typescript-utils'
22import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' 20import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
23import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 21import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
@@ -41,61 +39,19 @@ import {
41} from '../../types/models/video' 39} from '../../types/models/video'
42import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' 40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
43import { AccountModel } from '../account/account' 41import { AccountModel } from '../account/account'
44import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor' 42import { ActorModel } from '../actor/actor'
45import { 43import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared'
46 buildBlockedAccountSQL, 44import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
47 buildBlockedAccountSQLOptimized,
48 buildLocalAccountIdsIn,
49 getCommentSort,
50 searchAttribute,
51 throwIfNotValid
52} from '../utils'
53import { VideoModel } from './video' 45import { VideoModel } from './video'
54import { VideoChannelModel } from './video-channel' 46import { VideoChannelModel } from './video-channel'
55 47
56export enum ScopeNames { 48export enum ScopeNames {
57 WITH_ACCOUNT = 'WITH_ACCOUNT', 49 WITH_ACCOUNT = 'WITH_ACCOUNT',
58 WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
59 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO', 50 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
60 WITH_VIDEO = 'WITH_VIDEO', 51 WITH_VIDEO = 'WITH_VIDEO'
61 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
62} 52}
63 53
64@Scopes(() => ({ 54@Scopes(() => ({
65 [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
66 return {
67 attributes: {
68 include: [
69 [
70 Sequelize.literal(
71 '(' +
72 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
73 'SELECT COUNT("replies"."id") ' +
74 'FROM "videoComment" AS "replies" ' +
75 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
76 'AND "deletedAt" IS NULL ' +
77 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
78 ')'
79 ),
80 'totalReplies'
81 ],
82 [
83 Sequelize.literal(
84 '(' +
85 'SELECT COUNT("replies"."id") ' +
86 'FROM "videoComment" AS "replies" ' +
87 'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
88 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
89 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
90 'AND "replies"."accountId" = "videoChannel"."accountId"' +
91 ')'
92 ),
93 'totalRepliesFromVideoAuthor'
94 ]
95 ]
96 }
97 } as FindOptions
98 },
99 [ScopeNames.WITH_ACCOUNT]: { 55 [ScopeNames.WITH_ACCOUNT]: {
100 include: [ 56 include: [
101 { 57 {
@@ -103,22 +59,6 @@ export enum ScopeNames {
103 } 59 }
104 ] 60 ]
105 }, 61 },
106 [ScopeNames.WITH_ACCOUNT_FOR_API]: {
107 include: [
108 {
109 model: AccountModel.unscoped(),
110 include: [
111 {
112 attributes: {
113 exclude: unusedActorAttributesForAPI
114 },
115 model: ActorModel, // Default scope includes avatar and server
116 required: true
117 }
118 ]
119 }
120 ]
121 },
122 [ScopeNames.WITH_IN_REPLY_TO]: { 62 [ScopeNames.WITH_IN_REPLY_TO]: {
123 include: [ 63 include: [
124 { 64 {
@@ -252,6 +192,18 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
252 }) 192 })
253 CommentAbuses: VideoCommentAbuseModel[] 193 CommentAbuses: VideoCommentAbuseModel[]
254 194
195 // ---------------------------------------------------------------------------
196
197 static getSQLAttributes (tableName: string, aliasPrefix = '') {
198 return buildSQLAttributes({
199 model: this,
200 tableName,
201 aliasPrefix
202 })
203 }
204
205 // ---------------------------------------------------------------------------
206
255 static loadById (id: number, t?: Transaction): Promise<MComment> { 207 static loadById (id: number, t?: Transaction): Promise<MComment> {
256 const query: FindOptions = { 208 const query: FindOptions = {
257 where: { 209 where: {
@@ -319,93 +271,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
319 searchAccount?: string 271 searchAccount?: string
320 searchVideo?: string 272 searchVideo?: string
321 }) { 273 }) {
322 const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters 274 const queryOptions: ListVideoCommentsOptions = {
275 ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
323 276
324 const where: WhereOptions = { 277 selectType: 'api',
325 deletedAt: null 278 notDeleted: true
326 }
327
328 const whereAccount: WhereOptions = {}
329 const whereActor: WhereOptions = {}
330 const whereVideo: WhereOptions = {}
331
332 if (isLocal === true) {
333 Object.assign(whereActor, {
334 serverId: null
335 })
336 } else if (isLocal === false) {
337 Object.assign(whereActor, {
338 serverId: {
339 [Op.ne]: null
340 }
341 })
342 }
343
344 if (search) {
345 Object.assign(where, {
346 [Op.or]: [
347 searchAttribute(search, 'text'),
348 searchAttribute(search, '$Account.Actor.preferredUsername$'),
349 searchAttribute(search, '$Account.name$'),
350 searchAttribute(search, '$Video.name$')
351 ]
352 })
353 }
354
355 if (searchAccount) {
356 Object.assign(whereActor, {
357 [Op.or]: [
358 searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
359 searchAttribute(searchAccount, '$Account.name$')
360 ]
361 })
362 }
363
364 if (searchVideo) {
365 Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
366 }
367
368 if (exists(onLocalVideo)) {
369 Object.assign(whereVideo, { remote: !onLocalVideo })
370 }
371
372 const getQuery = (forCount: boolean) => {
373 return {
374 offset: start,
375 limit: count,
376 order: getCommentSort(sort),
377 where,
378 include: [
379 {
380 model: AccountModel.unscoped(),
381 required: true,
382 where: whereAccount,
383 include: [
384 {
385 attributes: {
386 exclude: unusedActorAttributesForAPI
387 },
388 model: forCount === true
389 ? ActorModel.unscoped() // Default scope includes avatar and server
390 : ActorModel,
391 required: true,
392 where: whereActor
393 }
394 ]
395 },
396 {
397 model: VideoModel.unscoped(),
398 required: true,
399 where: whereVideo
400 }
401 ]
402 }
403 } 279 }
404 280
405 return Promise.all([ 281 return Promise.all([
406 VideoCommentModel.count(getQuery(true)), 282 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
407 VideoCommentModel.findAll(getQuery(false)) 283 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
408 ]).then(([ total, data ]) => ({ total, data })) 284 ]).then(([ rows, count ]) => {
285 return { total: count, data: rows }
286 })
409 } 287 }
410 288
411 static async listThreadsForApi (parameters: { 289 static async listThreadsForApi (parameters: {
@@ -416,67 +294,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
416 sort: string 294 sort: string
417 user?: MUserAccountId 295 user?: MUserAccountId
418 }) { 296 }) {
419 const { videoId, isVideoOwned, start, count, sort, user } = parameters 297 const { videoId, user } = parameters
420 298
421 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 299 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
422 300
423 const accountBlockedWhere = { 301 const commonOptions: ListVideoCommentsOptions = {
424 accountId: { 302 selectType: 'api',
425 [Op.notIn]: Sequelize.literal( 303 videoId,
426 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')' 304 blockerAccountIds
427 )
428 }
429 } 305 }
430 306
431 const queryList = { 307 const listOptions: ListVideoCommentsOptions = {
432 offset: start, 308 ...commonOptions,
433 limit: count, 309 ...pick(parameters, [ 'sort', 'start', 'count' ]),
434 order: getCommentSort(sort), 310
435 where: { 311 isThread: true,
436 [Op.and]: [ 312 includeReplyCounters: true
437 {
438 videoId
439 },
440 {
441 inReplyToCommentId: null
442 },
443 {
444 [Op.or]: [
445 accountBlockedWhere,
446 {
447 accountId: null
448 }
449 ]
450 }
451 ]
452 }
453 } 313 }
454 314
455 const findScopesList: (string | ScopeOptions)[] = [ 315 const countOptions: ListVideoCommentsOptions = {
456 ScopeNames.WITH_ACCOUNT_FOR_API, 316 ...commonOptions,
457 {
458 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
459 }
460 ]
461 317
462 const countScopesList: ScopeOptions[] = [ 318 isThread: true
463 { 319 }
464 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
465 }
466 ]
467 320
468 const notDeletedQueryCount = { 321 const notDeletedCountOptions: ListVideoCommentsOptions = {
469 where: { 322 ...commonOptions,
470 videoId, 323
471 deletedAt: null, 324 notDeleted: true
472 ...accountBlockedWhere
473 }
474 } 325 }
475 326
476 return Promise.all([ 327 return Promise.all([
477 VideoCommentModel.scope(findScopesList).findAll(queryList), 328 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
478 VideoCommentModel.scope(countScopesList).count(queryList), 329 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
479 VideoCommentModel.count(notDeletedQueryCount) 330 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
480 ]).then(([ rows, count, totalNotDeletedComments ]) => { 331 ]).then(([ rows, count, totalNotDeletedComments ]) => {
481 return { total: count, data: rows, totalNotDeletedComments } 332 return { total: count, data: rows, totalNotDeletedComments }
482 }) 333 })
@@ -484,54 +335,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
484 335
485 static async listThreadCommentsForApi (parameters: { 336 static async listThreadCommentsForApi (parameters: {
486 videoId: number 337 videoId: number
487 isVideoOwned: boolean
488 threadId: number 338 threadId: number
489 user?: MUserAccountId 339 user?: MUserAccountId
490 }) { 340 }) {
491 const { videoId, threadId, user, isVideoOwned } = parameters 341 const { user } = parameters
492 342
493 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 343 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
494 344
495 const query = { 345 const queryOptions: ListVideoCommentsOptions = {
496 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, 346 ...pick(parameters, [ 'videoId', 'threadId' ]),
497 where: {
498 videoId,
499 [Op.and]: [
500 {
501 [Op.or]: [
502 { id: threadId },
503 { originCommentId: threadId }
504 ]
505 },
506 {
507 [Op.or]: [
508 {
509 accountId: {
510 [Op.notIn]: Sequelize.literal(
511 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
512 )
513 }
514 },
515 {
516 accountId: null
517 }
518 ]
519 }
520 ]
521 }
522 }
523 347
524 const scopes: any[] = [ 348 selectType: 'api',
525 ScopeNames.WITH_ACCOUNT_FOR_API, 349 sort: 'createdAt',
526 { 350
527 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] 351 blockerAccountIds,
528 } 352 includeReplyCounters: true
529 ] 353 }
530 354
531 return Promise.all([ 355 return Promise.all([
532 VideoCommentModel.count(query), 356 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
533 VideoCommentModel.scope(scopes).findAll(query) 357 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
534 ]).then(([ total, data ]) => ({ total, data })) 358 ]).then(([ rows, count ]) => {
359 return { total: count, data: rows }
360 })
535 } 361 }
536 362
537 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> { 363 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@@ -559,31 +385,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
559 .findAll(query) 385 .findAll(query)
560 } 386 }
561 387
562 static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) { 388 static async listAndCountByVideoForAP (parameters: {
563 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ 389 video: MVideoImmutable
390 start: number
391 count: number
392 }) {
393 const { video } = parameters
394
395 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
396
397 const queryOptions: ListVideoCommentsOptions = {
398 ...pick(parameters, [ 'start', 'count' ]),
399
400 selectType: 'comment-only',
564 videoId: video.id, 401 videoId: video.id,
565 isVideoOwned: video.isOwned() 402 sort: 'createdAt',
566 })
567 403
568 const query = { 404 blockerAccountIds
569 order: [ [ 'createdAt', 'ASC' ] ] as Order,
570 offset: start,
571 limit: count,
572 where: {
573 videoId: video.id,
574 accountId: {
575 [Op.notIn]: Sequelize.literal(
576 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
577 )
578 }
579 },
580 transaction: t
581 } 405 }
582 406
583 return Promise.all([ 407 return Promise.all([
584 VideoCommentModel.count(query), 408 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
585 VideoCommentModel.findAll<MComment>(query) 409 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
586 ]).then(([ total, data ]) => ({ total, data })) 410 ]).then(([ rows, count ]) => {
411 return { total: count, data: rows }
412 })
587 } 413 }
588 414
589 static async listForFeed (parameters: { 415 static async listForFeed (parameters: {
@@ -592,97 +418,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
592 videoId?: number 418 videoId?: number
593 accountId?: number 419 accountId?: number
594 videoChannelId?: number 420 videoChannelId?: number
595 }): Promise<MCommentOwnerVideoFeed[]> { 421 }) {
596 const serverActor = await getServerActor() 422 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
597 const { start, count, videoId, accountId, videoChannelId } = parameters
598
599 const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
600 '"VideoCommentModel"."accountId"',
601 [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
602 )
603 423
604 if (accountId) { 424 const queryOptions: ListVideoCommentsOptions = {
605 whereAnd.push({ 425 ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
606 accountId
607 })
608 }
609 426
610 const accountWhere = { 427 selectType: 'feed',
611 [Op.and]: whereAnd
612 }
613 428
614 const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined 429 sort: '-createdAt',
430 onPublicVideo: true,
431 notDeleted: true,
615 432
616 const query = { 433 blockerAccountIds
617 order: [ [ 'createdAt', 'DESC' ] ] as Order,
618 offset: start,
619 limit: count,
620 where: {
621 deletedAt: null,
622 accountId: accountWhere
623 },
624 include: [
625 {
626 attributes: [ 'name', 'uuid' ],
627 model: VideoModel.unscoped(),
628 required: true,
629 where: {
630 privacy: VideoPrivacy.PUBLIC
631 },
632 include: [
633 {
634 attributes: [ 'accountId' ],
635 model: VideoChannelModel.unscoped(),
636 required: true,
637 where: videoChannelWhere
638 }
639 ]
640 }
641 ]
642 } 434 }
643 435
644 if (videoId) query.where['videoId'] = videoId 436 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
645
646 return VideoCommentModel
647 .scope([ ScopeNames.WITH_ACCOUNT ])
648 .findAll(query)
649 } 437 }
650 438
651 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) { 439 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
652 const accountWhere = filter.onVideosOfAccount 440 const queryOptions: ListVideoCommentsOptions = {
653 ? { id: filter.onVideosOfAccount.id } 441 selectType: 'comment-only',
654 : {}
655 442
656 const query = { 443 accountId: ofAccount.id,
657 limit: 1000, 444 videoAccountOwnerId: filter.onVideosOfAccount?.id,
658 where: { 445
659 deletedAt: null, 446 notDeleted: true,
660 accountId: ofAccount.id 447 count: 5000
661 },
662 include: [
663 {
664 model: VideoModel,
665 required: true,
666 include: [
667 {
668 model: VideoChannelModel,
669 required: true,
670 include: [
671 {
672 model: AccountModel,
673 required: true,
674 where: accountWhere
675 }
676 ]
677 }
678 ]
679 }
680 ]
681 } 448 }
682 449
683 return VideoCommentModel 450 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
684 .scope([ ScopeNames.WITH_ACCOUNT ])
685 .findAll(query)
686 } 451 }
687 452
688 static async getStats () { 453 static async getStats () {
@@ -750,9 +515,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
750 } 515 }
751 516
752 isOwned () { 517 isOwned () {
753 if (!this.Account) { 518 if (!this.Account) return false
754 return false
755 }
756 519
757 return this.Account.isOwned() 520 return this.Account.isOwned()
758 } 521 }
@@ -906,22 +669,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
906 } 669 }
907 670
908 private static async buildBlockerAccountIds (options: { 671 private static async buildBlockerAccountIds (options: {
909 videoId: number 672 user: MUserAccountId
910 isVideoOwned: boolean 673 }): Promise<number[]> {
911 user?: MUserAccountId 674 const { user } = options
912 }) {
913 const { videoId, user, isVideoOwned } = options
914 675
915 const serverActor = await getServerActor() 676 const serverActor = await getServerActor()
916 const blockerAccountIds = [ serverActor.Account.id ] 677 const blockerAccountIds = [ serverActor.Account.id ]
917 678
918 if (user) blockerAccountIds.push(user.Account.id) 679 if (user) blockerAccountIds.push(user.Account.id)
919 680
920 if (isVideoOwned) {
921 const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
922 if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
923 }
924
925 return blockerAccountIds 681 return blockerAccountIds
926 } 682 }
927} 683}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 9c4e6d078..07bc13de1 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -21,6 +21,7 @@ import {
21import validator from 'validator' 21import validator from 'validator'
22import { logger } from '@server/helpers/logger' 22import { logger } from '@server/helpers/logger'
23import { extractVideo } from '@server/helpers/video' 23import { extractVideo } from '@server/helpers/video'
24import { CONFIG } from '@server/initializers/config'
24import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url' 25import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
25import { 26import {
26 getHLSPrivateFileUrl, 27 getHLSPrivateFileUrl,
@@ -50,11 +51,9 @@ import {
50} from '../../initializers/constants' 51} from '../../initializers/constants'
51import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file' 52import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
52import { VideoRedundancyModel } from '../redundancy/video-redundancy' 53import { VideoRedundancyModel } from '../redundancy/video-redundancy'
53import { doesExist } from '../shared' 54import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
54import { parseAggregateResult, throwIfNotValid } from '../utils'
55import { VideoModel } from './video' 55import { VideoModel } from './video'
56import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 56import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
57import { CONFIG } from '@server/initializers/config'
58 57
59export enum ScopeNames { 58export enum ScopeNames {
60 WITH_VIDEO = 'WITH_VIDEO', 59 WITH_VIDEO = 'WITH_VIDEO',
@@ -266,7 +265,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
266 static doesInfohashExist (infoHash: string) { 265 static doesInfohashExist (infoHash: string) {
267 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 266 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
268 267
269 return doesExist(query, { infoHash }) 268 return doesExist(this.sequelize, query, { infoHash })
270 } 269 }
271 270
272 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { 271 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
@@ -282,14 +281,14 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
282 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' + 281 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
283 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1' 282 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1'
284 283
285 return doesExist(query, { filename }) 284 return doesExist(this.sequelize, query, { filename })
286 } 285 }
287 286
288 static async doesOwnedWebTorrentVideoFileExist (filename: string) { 287 static async doesOwnedWebTorrentVideoFileExist (filename: string) {
289 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' + 288 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
290 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1` 289 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
291 290
292 return doesExist(query, { filename }) 291 return doesExist(this.sequelize, query, { filename })
293 } 292 }
294 293
295 static loadByFilename (filename: string) { 294 static loadByFilename (filename: string) {
@@ -439,7 +438,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
439 if (!element) return videoFile.save({ transaction }) 438 if (!element) return videoFile.save({ transaction })
440 439
441 for (const k of Object.keys(videoFile.toJSON())) { 440 for (const k of Object.keys(videoFile.toJSON())) {
442 element[k] = videoFile[k] 441 element.set(k, videoFile[k])
443 } 442 }
444 443
445 return element.save({ transaction }) 444 return element.save({ transaction })
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index da6b92c7a..c040e0fda 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -22,7 +22,7 @@ import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../help
22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' 22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants' 23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
24import { UserModel } from '../user/user' 24import { UserModel } from '../user/user'
25import { getSort, searchAttribute, throwIfNotValid } from '../utils' 25import { getSort, searchAttribute, throwIfNotValid } from '../shared'
26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' 26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
27import { VideoChannelSyncModel } from './video-channel-sync' 27import { VideoChannelSyncModel } from './video-channel-sync'
28 28
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index 7181b5599..48f4ed5a9 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -31,7 +31,7 @@ import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/
31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 32import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
33import { AccountModel } from '../account/account' 33import { AccountModel } from '../account/account'
34import { getSort, throwIfNotValid } from '../utils' 34import { getSort, throwIfNotValid } from '../shared'
35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' 35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
36import { VideoPlaylistModel } from './video-playlist' 36import { VideoPlaylistModel } from './video-playlist'
37 37
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 8bbe54c49..faf4bea78 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -21,12 +21,8 @@ import { activityPubCollectionPagination } from '@server/lib/activitypub/collect
21import { MAccountId, MChannelId } from '@server/types/models' 21import { MAccountId, MChannelId } from '@server/types/models'
22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils' 22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
23import { buildUUID, uuidToShort } from '@shared/extra-utils' 23import { buildUUID, uuidToShort } from '@shared/extra-utils'
24import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
24import { AttributesOnly } from '@shared/typescript-utils' 25import { AttributesOnly } from '@shared/typescript-utils'
25import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
26import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
27import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
28import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
29import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
30import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 26import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
31import { 27import {
32 isVideoPlaylistDescriptionValid, 28 isVideoPlaylistDescriptionValid,
@@ -53,7 +49,6 @@ import {
53} from '../../types/models/video/video-playlist' 49} from '../../types/models/video/video-playlist'
54import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' 50import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
55import { ActorModel } from '../actor/actor' 51import { ActorModel } from '../actor/actor'
56import { setAsUpdated } from '../shared'
57import { 52import {
58 buildServerIdsFollowedBy, 53 buildServerIdsFollowedBy,
59 buildTrigramSearchIndex, 54 buildTrigramSearchIndex,
@@ -61,8 +56,9 @@ import {
61 createSimilarityAttribute, 56 createSimilarityAttribute,
62 getPlaylistSort, 57 getPlaylistSort,
63 isOutdated, 58 isOutdated,
59 setAsUpdated,
64 throwIfNotValid 60 throwIfNotValid
65} from '../utils' 61} from '../shared'
66import { ThumbnailModel } from './thumbnail' 62import { ThumbnailModel } from './thumbnail'
67import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 63import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
68import { VideoPlaylistElementModel } from './video-playlist-element' 64import { VideoPlaylistElementModel } from './video-playlist-element'
@@ -641,7 +637,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
641 } 637 }
642 638
643 setAsRefreshed () { 639 setAsRefreshed () {
644 return setAsUpdated('videoPlaylist', this.id) 640 return setAsUpdated({ sequelize: this.sequelize, table: 'videoPlaylist', id: this.id })
645 } 641 }
646 642
647 setVideosLength (videosLength: number) { 643 setVideosLength (videosLength: number) {
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index f2190037e..b4de2b20f 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -7,7 +7,7 @@ import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models' 7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models'
8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video' 8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
9import { ActorModel } from '../actor/actor' 9import { ActorModel } from '../actor/actor'
10import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' 10import { buildLocalActorIdsIn, throwIfNotValid } from '../shared'
11import { VideoModel } from './video' 11import { VideoModel } from './video'
12 12
13enum ScopeNames { 13enum ScopeNames {
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index 0386edf28..a85c79c9f 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -37,8 +37,7 @@ import {
37 WEBSERVER 37 WEBSERVER
38} from '../../initializers/constants' 38} from '../../initializers/constants'
39import { VideoRedundancyModel } from '../redundancy/video-redundancy' 39import { VideoRedundancyModel } from '../redundancy/video-redundancy'
40import { doesExist } from '../shared' 40import { doesExist, throwIfNotValid } from '../shared'
41import { throwIfNotValid } from '../utils'
42import { VideoModel } from './video' 41import { VideoModel } from './video'
43 42
44@Table({ 43@Table({
@@ -138,7 +137,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
138 static doesInfohashExist (infoHash: string) { 137 static doesInfohashExist (infoHash: string) {
139 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1' 138 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
140 139
141 return doesExist(query, { infoHash }) 140 return doesExist(this.sequelize, query, { infoHash })
142 } 141 }
143 142
144 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) { 143 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
@@ -237,7 +236,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
237 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` + 236 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
238 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1` 237 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
239 238
240 return doesExist(query, { videoUUID }) 239 return doesExist(this.sequelize, query, { videoUUID })
241 } 240 }
242 241
243 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) { 242 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 56cc45cfe..1a10d2da2 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -32,7 +32,7 @@ import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFil
32import { VideoPathManager } from '@server/lib/video-path-manager' 32import { VideoPathManager } from '@server/lib/video-path-manager'
33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' 33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
34import { getServerActor } from '@server/models/application/application' 34import { getServerActor } from '@server/models/application/application'
35import { ModelCache } from '@server/models/model-cache' 35import { ModelCache } from '@server/models/shared/model-cache'
36import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils' 36import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
37import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils' 37import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils'
38import { 38import {
@@ -103,10 +103,9 @@ import { VideoRedundancyModel } from '../redundancy/video-redundancy'
103import { ServerModel } from '../server/server' 103import { ServerModel } from '../server/server'
104import { TrackerModel } from '../server/tracker' 104import { TrackerModel } from '../server/tracker'
105import { VideoTrackerModel } from '../server/video-tracker' 105import { VideoTrackerModel } from '../server/video-tracker'
106import { setAsUpdated } from '../shared' 106import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
107import { UserModel } from '../user/user' 107import { UserModel } from '../user/user'
108import { UserVideoHistoryModel } from '../user/user-video-history' 108import { UserVideoHistoryModel } from '../user/user-video-history'
109import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
110import { VideoViewModel } from '../view/video-view' 109import { VideoViewModel } from '../view/video-view'
111import { 110import {
112 videoFilesModelToFormattedJSON, 111 videoFilesModelToFormattedJSON,
@@ -1871,7 +1870,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1871 } 1870 }
1872 1871
1873 setAsRefreshed (transaction?: Transaction) { 1872 setAsRefreshed (transaction?: Transaction) {
1874 return setAsUpdated('video', this.id, transaction) 1873 return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
1875 } 1874 }
1876 1875
1877 // --------------------------------------------------------------------------- 1876 // ---------------------------------------------------------------------------
diff --git a/server/tests/api/activitypub/cleaner.ts b/server/tests/api/activitypub/cleaner.ts
index eb6779123..1c1495022 100644
--- a/server/tests/api/activitypub/cleaner.ts
+++ b/server/tests/api/activitypub/cleaner.ts
@@ -148,7 +148,7 @@ describe('Test AP cleaner', function () {
148 it('Should destroy server 3 internal shares and correctly clean them', async function () { 148 it('Should destroy server 3 internal shares and correctly clean them', async function () {
149 this.timeout(20000) 149 this.timeout(20000)
150 150
151 const preCount = await servers[0].sql.getCount('videoShare') 151 const preCount = await servers[0].sql.getVideoShareCount()
152 expect(preCount).to.equal(6) 152 expect(preCount).to.equal(6)
153 153
154 await servers[2].sql.deleteAll('videoShare') 154 await servers[2].sql.deleteAll('videoShare')
@@ -156,7 +156,7 @@ describe('Test AP cleaner', function () {
156 await waitJobs(servers) 156 await waitJobs(servers)
157 157
158 // Still 6 because we don't have remote shares on local videos 158 // Still 6 because we don't have remote shares on local videos
159 const postCount = await servers[0].sql.getCount('videoShare') 159 const postCount = await servers[0].sql.getVideoShareCount()
160 expect(postCount).to.equal(6) 160 expect(postCount).to.equal(6)
161 }) 161 })
162 162
@@ -185,7 +185,7 @@ describe('Test AP cleaner', function () {
185 async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') { 185 async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') {
186 const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` + 186 const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` +
187 `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'` 187 `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'`
188 const res = await servers[0].sql.selectQuery(query) 188 const res = await servers[0].sql.selectQuery<{ url: string }>(query)
189 189
190 for (const rate of res) { 190 for (const rate of res) {
191 const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`) 191 const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`)
@@ -231,7 +231,7 @@ describe('Test AP cleaner', function () {
231 const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` + 231 const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` +
232 `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'` 232 `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'`
233 233
234 const res = await servers[0].sql.selectQuery(query) 234 const res = await servers[0].sql.selectQuery<{ url: string, videoUUID: string }>(query)
235 235
236 for (const comment of res) { 236 for (const comment of res) {
237 const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`) 237 const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`)
diff --git a/server/tests/api/check-params/redundancy.ts b/server/tests/api/check-params/redundancy.ts
index 908407b9a..73dfd489d 100644
--- a/server/tests/api/check-params/redundancy.ts
+++ b/server/tests/api/check-params/redundancy.ts
@@ -24,7 +24,7 @@ describe('Test server redundancy API validators', function () {
24 // --------------------------------------------------------------- 24 // ---------------------------------------------------------------
25 25
26 before(async function () { 26 before(async function () {
27 this.timeout(80000) 27 this.timeout(160000)
28 28
29 servers = await createMultipleServers(2) 29 servers = await createMultipleServers(2)
30 30
diff --git a/server/tests/api/live/live-fast-restream.ts b/server/tests/api/live/live-fast-restream.ts
index c0bb8d529..f6959b83c 100644
--- a/server/tests/api/live/live-fast-restream.ts
+++ b/server/tests/api/live/live-fast-restream.ts
@@ -78,9 +78,15 @@ describe('Fast restream in live', function () {
78 const video = await server.videos.get({ id: liveId }) 78 const video = await server.videos.get({ id: liveId })
79 expect(video.streamingPlaylists).to.have.lengthOf(1) 79 expect(video.streamingPlaylists).to.have.lengthOf(1)
80 80
81 await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 }) 81 try {
82 await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) 82 await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
83 await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) 83 await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
84 await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
85 } catch (err) {
86 // FIXME: try to debug error in CI "Unexpected end of JSON input"
87 console.error(err)
88 throw err
89 }
84 90
85 await wait(100) 91 await wait(100)
86 } 92 }
@@ -129,7 +135,7 @@ describe('Fast restream in live', function () {
129 await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' }) 135 await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
130 }) 136 })
131 137
132 it('Should correctly fast reastream in a permanent live with and without save replay', async function () { 138 it('Should correctly fast restream in a permanent live with and without save replay', async function () {
133 this.timeout(480000) 139 this.timeout(480000)
134 140
135 // A test can take a long time, so prefer to run them in parallel 141 // A test can take a long time, so prefer to run them in parallel
diff --git a/server/tests/api/notifications/moderation-notifications.ts b/server/tests/api/notifications/moderation-notifications.ts
index b127a7a31..c7b9b5fb0 100644
--- a/server/tests/api/notifications/moderation-notifications.ts
+++ b/server/tests/api/notifications/moderation-notifications.ts
@@ -34,7 +34,7 @@ describe('Test moderation notifications', function () {
34 let emails: object[] = [] 34 let emails: object[] = []
35 35
36 before(async function () { 36 before(async function () {
37 this.timeout(120000) 37 this.timeout(50000)
38 38
39 const res = await prepareNotificationsTest(3) 39 const res = await prepareNotificationsTest(3)
40 emails = res.emails 40 emails = res.emails
@@ -60,7 +60,7 @@ describe('Test moderation notifications', function () {
60 }) 60 })
61 61
62 it('Should not send a notification to moderators on local abuse reported by an admin', async function () { 62 it('Should not send a notification to moderators on local abuse reported by an admin', async function () {
63 this.timeout(20000) 63 this.timeout(50000)
64 64
65 const name = 'video for abuse ' + buildUUID() 65 const name = 'video for abuse ' + buildUUID()
66 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 66 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -72,7 +72,7 @@ describe('Test moderation notifications', function () {
72 }) 72 })
73 73
74 it('Should send a notification to moderators on local video abuse', async function () { 74 it('Should send a notification to moderators on local video abuse', async function () {
75 this.timeout(20000) 75 this.timeout(50000)
76 76
77 const name = 'video for abuse ' + buildUUID() 77 const name = 'video for abuse ' + buildUUID()
78 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 78 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -84,7 +84,7 @@ describe('Test moderation notifications', function () {
84 }) 84 })
85 85
86 it('Should send a notification to moderators on remote video abuse', async function () { 86 it('Should send a notification to moderators on remote video abuse', async function () {
87 this.timeout(20000) 87 this.timeout(50000)
88 88
89 const name = 'video for abuse ' + buildUUID() 89 const name = 'video for abuse ' + buildUUID()
90 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 90 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -99,7 +99,7 @@ describe('Test moderation notifications', function () {
99 }) 99 })
100 100
101 it('Should send a notification to moderators on local comment abuse', async function () { 101 it('Should send a notification to moderators on local comment abuse', async function () {
102 this.timeout(20000) 102 this.timeout(50000)
103 103
104 const name = 'video for abuse ' + buildUUID() 104 const name = 'video for abuse ' + buildUUID()
105 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 105 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -118,7 +118,7 @@ describe('Test moderation notifications', function () {
118 }) 118 })
119 119
120 it('Should send a notification to moderators on remote comment abuse', async function () { 120 it('Should send a notification to moderators on remote comment abuse', async function () {
121 this.timeout(20000) 121 this.timeout(50000)
122 122
123 const name = 'video for abuse ' + buildUUID() 123 const name = 'video for abuse ' + buildUUID()
124 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 124 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -140,7 +140,7 @@ describe('Test moderation notifications', function () {
140 }) 140 })
141 141
142 it('Should send a notification to moderators on local account abuse', async function () { 142 it('Should send a notification to moderators on local account abuse', async function () {
143 this.timeout(20000) 143 this.timeout(50000)
144 144
145 const username = 'user' + new Date().getTime() 145 const username = 'user' + new Date().getTime()
146 const { account } = await servers[0].users.create({ username, password: 'donald' }) 146 const { account } = await servers[0].users.create({ username, password: 'donald' })
@@ -153,7 +153,7 @@ describe('Test moderation notifications', function () {
153 }) 153 })
154 154
155 it('Should send a notification to moderators on remote account abuse', async function () { 155 it('Should send a notification to moderators on remote account abuse', async function () {
156 this.timeout(20000) 156 this.timeout(50000)
157 157
158 const username = 'user' + new Date().getTime() 158 const username = 'user' + new Date().getTime()
159 const tmpToken = await servers[0].users.generateUserAndToken(username) 159 const tmpToken = await servers[0].users.generateUserAndToken(username)
@@ -512,10 +512,14 @@ describe('Test moderation notifications', function () {
512 }) 512 })
513 513
514 it('Should not send video publish notification if auto-blacklisted', async function () { 514 it('Should not send video publish notification if auto-blacklisted', async function () {
515 this.timeout(120000)
516
515 await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' }) 517 await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' })
516 }) 518 })
517 519
518 it('Should not send a local user subscription notification if auto-blacklisted', async function () { 520 it('Should not send a local user subscription notification if auto-blacklisted', async function () {
521 this.timeout(120000)
522
519 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' }) 523 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' })
520 }) 524 })
521 525
@@ -524,7 +528,7 @@ describe('Test moderation notifications', function () {
524 }) 528 })
525 529
526 it('Should send video published and unblacklist after video unblacklisted', async function () { 530 it('Should send video published and unblacklist after video unblacklisted', async function () {
527 this.timeout(40000) 531 this.timeout(120000)
528 532
529 await servers[0].blacklist.remove({ videoId: uuid }) 533 await servers[0].blacklist.remove({ videoId: uuid })
530 534
@@ -537,10 +541,14 @@ describe('Test moderation notifications', function () {
537 }) 541 })
538 542
539 it('Should send a local user subscription notification after removed from blacklist', async function () { 543 it('Should send a local user subscription notification after removed from blacklist', async function () {
544 this.timeout(120000)
545
540 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' }) 546 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' })
541 }) 547 })
542 548
543 it('Should send a remote user subscription notification after removed from blacklist', async function () { 549 it('Should send a remote user subscription notification after removed from blacklist', async function () {
550 this.timeout(120000)
551
544 await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' }) 552 await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' })
545 }) 553 })
546 554
@@ -576,7 +584,7 @@ describe('Test moderation notifications', function () {
576 }) 584 })
577 585
578 it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () { 586 it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () {
579 this.timeout(40000) 587 this.timeout(120000)
580 588
581 // In 2 seconds 589 // In 2 seconds
582 const updateAt = new Date(new Date().getTime() + 2000) 590 const updateAt = new Date(new Date().getTime() + 2000)
diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts
index 71ad35a43..869d437d5 100644
--- a/server/tests/api/object-storage/video-static-file-privacy.ts
+++ b/server/tests/api/object-storage/video-static-file-privacy.ts
@@ -120,7 +120,7 @@ describe('Object storage for video static file privacy', function () {
120 // --------------------------------------------------------------------------- 120 // ---------------------------------------------------------------------------
121 121
122 it('Should upload a private video and have appropriate object storage ACL', async function () { 122 it('Should upload a private video and have appropriate object storage ACL', async function () {
123 this.timeout(60000) 123 this.timeout(120000)
124 124
125 { 125 {
126 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 126 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
@@ -138,7 +138,7 @@ describe('Object storage for video static file privacy', function () {
138 }) 138 })
139 139
140 it('Should upload a public video and have appropriate object storage ACL', async function () { 140 it('Should upload a public video and have appropriate object storage ACL', async function () {
141 this.timeout(60000) 141 this.timeout(120000)
142 142
143 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED }) 143 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED })
144 await waitJobs([ server ]) 144 await waitJobs([ server ])
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index 643f1a531..0313845ef 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,3 +1,4 @@
1import './oauth'
1import './two-factor' 2import './two-factor'
2import './user-subscriptions' 3import './user-subscriptions'
3import './user-videos' 4import './user-videos'
diff --git a/server/tests/api/users/oauth.ts b/server/tests/api/users/oauth.ts
new file mode 100644
index 000000000..6a3da5ea2
--- /dev/null
+++ b/server/tests/api/users/oauth.ts
@@ -0,0 +1,192 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models'
6import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7
8describe('Test oauth', function () {
9 let server: PeerTubeServer
10
11 before(async function () {
12 this.timeout(30000)
13
14 server = await createSingleServer(1, {
15 rates_limit: {
16 login: {
17 max: 30
18 }
19 }
20 })
21
22 await setAccessTokensToServers([ server ])
23 })
24
25 describe('OAuth client', function () {
26
27 function expectInvalidClient (body: PeerTubeProblemDocument) {
28 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
29 expect(body.error).to.contain('client is invalid')
30 expect(body.type.startsWith('https://')).to.be.true
31 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
32 }
33
34 it('Should create a new client')
35
36 it('Should return the first client')
37
38 it('Should remove the last client')
39
40 it('Should not login with an invalid client id', async function () {
41 const client = { id: 'client', secret: server.store.client.secret }
42 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
43
44 expectInvalidClient(body)
45 })
46
47 it('Should not login with an invalid client secret', async function () {
48 const client = { id: server.store.client.id, secret: 'coucou' }
49 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
50
51 expectInvalidClient(body)
52 })
53 })
54
55 describe('Login', function () {
56
57 function expectInvalidCredentials (body: PeerTubeProblemDocument) {
58 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
59 expect(body.error).to.contain('credentials are invalid')
60 expect(body.type.startsWith('https://')).to.be.true
61 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
62 }
63
64 it('Should not login with an invalid username', async function () {
65 const user = { username: 'captain crochet', password: server.store.user.password }
66 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
67
68 expectInvalidCredentials(body)
69 })
70
71 it('Should not login with an invalid password', async function () {
72 const user = { username: server.store.user.username, password: 'mew_three' }
73 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
74
75 expectInvalidCredentials(body)
76 })
77
78 it('Should be able to login', async function () {
79 await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
80 })
81
82 it('Should be able to login with an insensitive username', async function () {
83 const user = { username: 'RoOt', password: server.store.user.password }
84 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
85
86 const user2 = { username: 'rOoT', password: server.store.user.password }
87 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
88
89 const user3 = { username: 'ROOt', password: server.store.user.password }
90 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
91 })
92 })
93
94 describe('Logout', function () {
95
96 it('Should logout (revoke token)', async function () {
97 await server.login.logout({ token: server.accessToken })
98 })
99
100 it('Should not be able to get the user information', async function () {
101 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
102 })
103
104 it('Should not be able to upload a video', async function () {
105 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
106 })
107
108 it('Should be able to login again', async function () {
109 const body = await server.login.login()
110 server.accessToken = body.access_token
111 server.refreshToken = body.refresh_token
112 })
113
114 it('Should be able to get my user information again', async function () {
115 await server.users.getMyInfo()
116 })
117
118 it('Should have an expired access token', async function () {
119 this.timeout(60000)
120
121 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
122 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
123
124 await killallServers([ server ])
125 await server.run()
126
127 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
128 })
129
130 it('Should not be able to refresh an access token with an expired refresh token', async function () {
131 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
132 })
133
134 it('Should refresh the token', async function () {
135 this.timeout(50000)
136
137 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
138 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
139
140 await killallServers([ server ])
141 await server.run()
142
143 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
144 server.accessToken = res.body.access_token
145 server.refreshToken = res.body.refresh_token
146 })
147
148 it('Should be able to get my user information again', async function () {
149 await server.users.getMyInfo()
150 })
151 })
152
153 describe('Custom token lifetime', function () {
154 before(async function () {
155 this.timeout(120_000)
156
157 await server.kill()
158 await server.run({
159 oauth2: {
160 token_lifetime: {
161 access_token: '2 seconds',
162 refresh_token: '2 seconds'
163 }
164 }
165 })
166 })
167
168 it('Should have a very short access token lifetime', async function () {
169 this.timeout(50000)
170
171 const { access_token: accessToken } = await server.login.login()
172 await server.users.getMyInfo({ token: accessToken })
173
174 await wait(3000)
175 await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
176 })
177
178 it('Should have a very short refresh token lifetime', async function () {
179 this.timeout(50000)
180
181 const { refresh_token: refreshToken } = await server.login.login()
182 await server.login.refreshToken({ refreshToken })
183
184 await wait(3000)
185 await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
186 })
187 })
188
189 after(async function () {
190 await cleanupTests([ server ])
191 })
192})
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 421b3ce16..93e2e489a 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -2,15 +2,8 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { testImage } from '@server/tests/shared' 4import { testImage } from '@server/tests/shared'
5import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' 5import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
6import { 6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7 cleanupTests,
8 createSingleServer,
9 killallServers,
10 makePutBodyRequest,
11 PeerTubeServer,
12 setAccessTokensToServers
13} from '@shared/server-commands'
14 7
15describe('Test users', function () { 8describe('Test users', function () {
16 let server: PeerTubeServer 9 let server: PeerTubeServer
@@ -39,166 +32,6 @@ describe('Test users', function () {
39 await server.plugins.install({ npmName: 'peertube-theme-background-red' }) 32 await server.plugins.install({ npmName: 'peertube-theme-background-red' })
40 }) 33 })
41 34
42 describe('OAuth client', function () {
43 it('Should create a new client')
44
45 it('Should return the first client')
46
47 it('Should remove the last client')
48
49 it('Should not login with an invalid client id', async function () {
50 const client = { id: 'client', secret: server.store.client.secret }
51 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
52
53 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
54 expect(body.error).to.contain('client is invalid')
55 expect(body.type.startsWith('https://')).to.be.true
56 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
57 })
58
59 it('Should not login with an invalid client secret', async function () {
60 const client = { id: server.store.client.id, secret: 'coucou' }
61 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
62
63 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
64 expect(body.error).to.contain('client is invalid')
65 expect(body.type.startsWith('https://')).to.be.true
66 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
67 })
68 })
69
70 describe('Login', function () {
71
72 it('Should not login with an invalid username', async function () {
73 const user = { username: 'captain crochet', password: server.store.user.password }
74 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
75
76 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
77 expect(body.error).to.contain('credentials are invalid')
78 expect(body.type.startsWith('https://')).to.be.true
79 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
80 })
81
82 it('Should not login with an invalid password', async function () {
83 const user = { username: server.store.user.username, password: 'mew_three' }
84 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
85
86 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
87 expect(body.error).to.contain('credentials are invalid')
88 expect(body.type.startsWith('https://')).to.be.true
89 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
90 })
91
92 it('Should not be able to upload a video', async function () {
93 token = 'my_super_token'
94
95 await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
96 })
97
98 it('Should not be able to follow', async function () {
99 token = 'my_super_token'
100
101 await server.follows.follow({
102 hosts: [ 'http://example.com' ],
103 token,
104 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
105 })
106 })
107
108 it('Should not be able to unfollow')
109
110 it('Should be able to login', async function () {
111 const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
112
113 token = body.access_token
114 })
115
116 it('Should be able to login with an insensitive username', async function () {
117 const user = { username: 'RoOt', password: server.store.user.password }
118 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
119
120 const user2 = { username: 'rOoT', password: server.store.user.password }
121 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
122
123 const user3 = { username: 'ROOt', password: server.store.user.password }
124 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
125 })
126 })
127
128 describe('Logout', function () {
129 it('Should logout (revoke token)', async function () {
130 await server.login.logout({ token: server.accessToken })
131 })
132
133 it('Should not be able to get the user information', async function () {
134 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
135 })
136
137 it('Should not be able to upload a video', async function () {
138 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
139 })
140
141 it('Should not be able to rate a video', async function () {
142 const path = '/api/v1/videos/'
143 const data = {
144 rating: 'likes'
145 }
146
147 const options = {
148 url: server.url,
149 path: path + videoId,
150 token: 'wrong token',
151 fields: data,
152 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
153 }
154 await makePutBodyRequest(options)
155 })
156
157 it('Should be able to login again', async function () {
158 const body = await server.login.login()
159 server.accessToken = body.access_token
160 server.refreshToken = body.refresh_token
161 })
162
163 it('Should be able to get my user information again', async function () {
164 await server.users.getMyInfo()
165 })
166
167 it('Should have an expired access token', async function () {
168 this.timeout(60000)
169
170 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
171 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
172
173 await killallServers([ server ])
174 await server.run()
175
176 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
177 })
178
179 it('Should not be able to refresh an access token with an expired refresh token', async function () {
180 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
181 })
182
183 it('Should refresh the token', async function () {
184 this.timeout(50000)
185
186 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
187 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
188
189 await killallServers([ server ])
190 await server.run()
191
192 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
193 server.accessToken = res.body.access_token
194 server.refreshToken = res.body.refresh_token
195 })
196
197 it('Should be able to get my user information again', async function () {
198 await server.users.getMyInfo()
199 })
200 })
201
202 describe('Creating a user', function () { 35 describe('Creating a user', function () {
203 36
204 it('Should be able to create a new user', async function () { 37 it('Should be able to create a new user', async function () {
@@ -512,6 +345,7 @@ describe('Test users', function () {
512 }) 345 })
513 346
514 describe('Updating another user', function () { 347 describe('Updating another user', function () {
348
515 it('Should be able to update another user', async function () { 349 it('Should be able to update another user', async function () {
516 await server.users.update({ 350 await server.users.update({
517 userId, 351 userId,
@@ -562,13 +396,6 @@ describe('Test users', function () {
562 }) 396 })
563 }) 397 })
564 398
565 describe('Video blacklists', function () {
566
567 it('Should be able to list my video blacklist', async function () {
568 await server.blacklist.list({ token: userToken })
569 })
570 })
571
572 describe('Remove a user', function () { 399 describe('Remove a user', function () {
573 400
574 before(async function () { 401 before(async function () {
@@ -653,8 +480,9 @@ describe('Test users', function () {
653 }) 480 })
654 481
655 describe('User blocking', function () { 482 describe('User blocking', function () {
656 let user16Id 483 let user16Id: number
657 let user16AccessToken 484 let user16AccessToken: string
485
658 const user16 = { 486 const user16 = {
659 username: 'user_16', 487 username: 'user_16',
660 password: 'my super password' 488 password: 'my super password'
diff --git a/server/tests/api/videos/video-channel-syncs.ts b/server/tests/api/videos/video-channel-syncs.ts
index 91291524d..dd483f95e 100644
--- a/server/tests/api/videos/video-channel-syncs.ts
+++ b/server/tests/api/videos/video-channel-syncs.ts
@@ -307,6 +307,7 @@ describe('Test channel synchronizations', function () {
307 }) 307 })
308 } 308 }
309 309
310 runSuite('youtube-dl') 310 // FIXME: suite is broken with youtube-dl
311 // runSuite('youtube-dl')
311 runSuite('yt-dlp') 312 runSuite('yt-dlp')
312}) 313})
diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts
index dc47f8a4a..e077cbf73 100644
--- a/server/tests/api/videos/video-comments.ts
+++ b/server/tests/api/videos/video-comments.ts
@@ -38,6 +38,8 @@ describe('Test video comments', function () {
38 await setDefaultAccountAvatar(server) 38 await setDefaultAccountAvatar(server)
39 39
40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1') 40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
41 await setDefaultChannelAvatar(server, 'user1_channel')
42 await setDefaultAccountAvatar(server, userAccessTokenServer1)
41 43
42 command = server.comments 44 command = server.comments
43 }) 45 })
@@ -232,16 +234,34 @@ describe('Test video comments', function () {
232 await command.addReply({ videoId, toCommentId: threadId2, text: text3 }) 234 await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
233 235
234 const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 }) 236 const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
235 expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1) 237 expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1)
238 expect(tree.comment.totalReplies).to.equal(2)
236 }) 239 })
237 }) 240 })
238 241
239 describe('All instance comments', function () { 242 describe('All instance comments', function () {
240 243
241 it('Should list instance comments as admin', async function () { 244 it('Should list instance comments as admin', async function () {
242 const { data } = await command.listForAdmin({ start: 0, count: 1 }) 245 {
246 const { data, total } = await command.listForAdmin({ start: 0, count: 1 })
243 247
244 expect(data[0].text).to.equal('my second answer to thread 4') 248 expect(total).to.equal(7)
249 expect(data).to.have.lengthOf(1)
250 expect(data[0].text).to.equal('my second answer to thread 4')
251 expect(data[0].account.name).to.equal('root')
252 expect(data[0].account.displayName).to.equal('root')
253 expect(data[0].account.avatars).to.have.lengthOf(2)
254 }
255
256 {
257 const { data, total } = await command.listForAdmin({ start: 1, count: 2 })
258
259 expect(total).to.equal(7)
260 expect(data).to.have.lengthOf(2)
261
262 expect(data[0].account.avatars).to.have.lengthOf(2)
263 expect(data[1].account.avatars).to.have.lengthOf(2)
264 }
245 }) 265 })
246 266
247 it('Should filter instance comments by isLocal', async function () { 267 it('Should filter instance comments by isLocal', async function () {
diff --git a/server/tests/external-plugins/auto-block-videos.ts b/server/tests/external-plugins/auto-block-videos.ts
index d14587c38..cadd02e8d 100644
--- a/server/tests/external-plugins/auto-block-videos.ts
+++ b/server/tests/external-plugins/auto-block-videos.ts
@@ -30,7 +30,7 @@ describe('Official plugin auto-block videos', function () {
30 let port: number 30 let port: number
31 31
32 before(async function () { 32 before(async function () {
33 this.timeout(60000) 33 this.timeout(120000)
34 34
35 servers = await createMultipleServers(2) 35 servers = await createMultipleServers(2)
36 await setAccessTokensToServers(servers) 36 await setAccessTokensToServers(servers)
diff --git a/server/tests/external-plugins/auto-mute.ts b/server/tests/external-plugins/auto-mute.ts
index 440b58bfd..cfed76e88 100644
--- a/server/tests/external-plugins/auto-mute.ts
+++ b/server/tests/external-plugins/auto-mute.ts
@@ -21,7 +21,7 @@ describe('Official plugin auto-mute', function () {
21 let port: number 21 let port: number
22 22
23 before(async function () { 23 before(async function () {
24 this.timeout(30000) 24 this.timeout(120000)
25 25
26 servers = await createMultipleServers(2) 26 servers = await createMultipleServers(2)
27 await setAccessTokensToServers(servers) 27 await setAccessTokensToServers(servers)
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 906dab1a3..7345f728a 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -189,7 +189,7 @@ describe('Test syndication feeds', () => {
189 const jsonObj = JSON.parse(json) 189 const jsonObj = JSON.parse(json)
190 expect(jsonObj.items.length).to.be.equal(1) 190 expect(jsonObj.items.length).to.be.equal(1)
191 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 191 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
192 expect(jsonObj.items[0].author.name).to.equal('root') 192 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
193 } 193 }
194 194
195 { 195 {
@@ -197,7 +197,7 @@ describe('Test syndication feeds', () => {
197 const jsonObj = JSON.parse(json) 197 const jsonObj = JSON.parse(json)
198 expect(jsonObj.items.length).to.be.equal(1) 198 expect(jsonObj.items.length).to.be.equal(1)
199 expect(jsonObj.items[0].title).to.equal('user video') 199 expect(jsonObj.items[0].title).to.equal('user video')
200 expect(jsonObj.items[0].author.name).to.equal('john') 200 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
201 } 201 }
202 202
203 for (const server of servers) { 203 for (const server of servers) {
@@ -223,7 +223,7 @@ describe('Test syndication feeds', () => {
223 const jsonObj = JSON.parse(json) 223 const jsonObj = JSON.parse(json)
224 expect(jsonObj.items.length).to.be.equal(1) 224 expect(jsonObj.items.length).to.be.equal(1)
225 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 225 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
226 expect(jsonObj.items[0].author.name).to.equal('root') 226 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
227 } 227 }
228 228
229 { 229 {
@@ -231,7 +231,7 @@ describe('Test syndication feeds', () => {
231 const jsonObj = JSON.parse(json) 231 const jsonObj = JSON.parse(json)
232 expect(jsonObj.items.length).to.be.equal(1) 232 expect(jsonObj.items.length).to.be.equal(1)
233 expect(jsonObj.items[0].title).to.equal('user video') 233 expect(jsonObj.items[0].title).to.equal('user video')
234 expect(jsonObj.items[0].author.name).to.equal('john') 234 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
235 } 235 }
236 236
237 for (const server of servers) { 237 for (const server of servers) {
diff --git a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
index c65b8d3a8..58bc27661 100644
--- a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
@@ -33,7 +33,17 @@ async function register ({
33 username: 'kefka', 33 username: 'kefka',
34 email: 'kefka@example.com', 34 email: 'kefka@example.com',
35 role: 0, 35 role: 0,
36 displayName: 'Kefka Palazzo' 36 displayName: 'Kefka Palazzo',
37 adminFlags: 1,
38 videoQuota: 42000,
39 videoQuotaDaily: 42100,
40
41 // Always use new value except for videoQuotaDaily field
42 userUpdater: ({ fieldName, currentValue, newValue }) => {
43 if (fieldName === 'videoQuotaDaily') return currentValue
44
45 return newValue
46 }
37 }) 47 })
38 }, 48 },
39 hookTokenValidity: (options) => { 49 hookTokenValidity: (options) => {
diff --git a/server/tests/fixtures/peertube-plugin-test-four/main.js b/server/tests/fixtures/peertube-plugin-test-four/main.js
index 3e848c49e..b10177b45 100644
--- a/server/tests/fixtures/peertube-plugin-test-four/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-four/main.js
@@ -76,6 +76,12 @@ async function register ({
76 return res.json({ serverConfig }) 76 return res.json({ serverConfig })
77 }) 77 })
78 78
79 router.get('/server-listening-config', async (req, res) => {
80 const config = await peertubeHelpers.config.getServerListeningConfig()
81
82 return res.json({ config })
83 })
84
79 router.get('/static-route', async (req, res) => { 85 router.get('/static-route', async (req, res) => {
80 const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute() 86 const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute()
81 87
diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
index ceab7b60d..fad5abf60 100644
--- a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
@@ -33,7 +33,18 @@ async function register ({
33 if (body.id === 'laguna' && body.password === 'laguna password') { 33 if (body.id === 'laguna' && body.password === 'laguna password') {
34 return Promise.resolve({ 34 return Promise.resolve({
35 username: 'laguna', 35 username: 'laguna',
36 email: 'laguna@example.com' 36 email: 'laguna@example.com',
37 displayName: 'Laguna Loire',
38 adminFlags: 1,
39 videoQuota: 42000,
40 videoQuotaDaily: 42100,
41
42 // Always use new value except for videoQuotaDaily field
43 userUpdater: ({ fieldName, currentValue, newValue }) => {
44 if (fieldName === 'videoQuotaDaily') return currentValue
45
46 return newValue
47 }
37 }) 48 })
38 } 49 }
39 50
diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index 19dccf26e..19ba9f278 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -250,7 +250,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
250 250
251 registerHook({ 251 registerHook({
252 target: 'filter:api.download.video.allowed.result', 252 target: 'filter:api.download.video.allowed.result',
253 handler: (result, params) => { 253 handler: async (result, params) => {
254 const loggedInUser = await peertubeHelpers.user.getAuthUser(params.res)
255 if (loggedInUser) return { allowed: true }
256
254 if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) { 257 if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
255 return { allowed: false, errorMessage: 'Cao Cao' } 258 return { allowed: false, errorMessage: 'Cao Cao' }
256 } 259 }
diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts
index 437777e90..e600f958f 100644
--- a/server/tests/plugins/external-auth.ts
+++ b/server/tests/plugins/external-auth.ts
@@ -2,7 +2,7 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { wait } from '@shared/core-utils' 4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, UserRole } from '@shared/models' 5import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 createSingleServer, 8 createSingleServer,
@@ -51,6 +51,7 @@ describe('Test external auth plugins', function () {
51 51
52 let kefkaAccessToken: string 52 let kefkaAccessToken: string
53 let kefkaRefreshToken: string 53 let kefkaRefreshToken: string
54 let kefkaId: number
54 55
55 let externalAuthToken: string 56 let externalAuthToken: string
56 57
@@ -156,6 +157,9 @@ describe('Test external auth plugins', function () {
156 expect(body.account.displayName).to.equal('cyan') 157 expect(body.account.displayName).to.equal('cyan')
157 expect(body.email).to.equal('cyan@example.com') 158 expect(body.email).to.equal('cyan@example.com')
158 expect(body.role.id).to.equal(UserRole.USER) 159 expect(body.role.id).to.equal(UserRole.USER)
160 expect(body.adminFlags).to.equal(UserAdminFlag.NONE)
161 expect(body.videoQuota).to.equal(5242880)
162 expect(body.videoQuotaDaily).to.equal(-1)
159 } 163 }
160 }) 164 })
161 165
@@ -178,6 +182,11 @@ describe('Test external auth plugins', function () {
178 expect(body.account.displayName).to.equal('Kefka Palazzo') 182 expect(body.account.displayName).to.equal('Kefka Palazzo')
179 expect(body.email).to.equal('kefka@example.com') 183 expect(body.email).to.equal('kefka@example.com')
180 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) 184 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR)
185 expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)
186 expect(body.videoQuota).to.equal(42000)
187 expect(body.videoQuotaDaily).to.equal(42100)
188
189 kefkaId = body.id
181 } 190 }
182 }) 191 })
183 192
@@ -240,6 +249,37 @@ describe('Test external auth plugins', function () {
240 expect(body.role.id).to.equal(UserRole.USER) 249 expect(body.role.id).to.equal(UserRole.USER)
241 }) 250 })
242 251
252 it('Should login Kefka and update the profile', async function () {
253 {
254 await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 })
255 await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' })
256
257 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
258 expect(body.username).to.equal('kefka')
259 expect(body.account.displayName).to.equal('kefka updated')
260 expect(body.videoQuota).to.equal(43000)
261 expect(body.videoQuotaDaily).to.equal(43100)
262 }
263
264 {
265 const res = await loginExternal({
266 server,
267 npmName: 'test-external-auth-one',
268 authName: 'external-auth-2',
269 username: 'kefka'
270 })
271
272 kefkaAccessToken = res.access_token
273 kefkaRefreshToken = res.refresh_token
274
275 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
276 expect(body.username).to.equal('kefka')
277 expect(body.account.displayName).to.equal('Kefka Palazzo')
278 expect(body.videoQuota).to.equal(42000)
279 expect(body.videoQuotaDaily).to.equal(43100)
280 }
281 })
282
243 it('Should not update an external auth email', async function () { 283 it('Should not update an external auth email', async function () {
244 await server.users.updateMe({ 284 await server.users.updateMe({
245 token: cyanAccessToken, 285 token: cyanAccessToken,
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index 083fd43ca..6724b3bf8 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -430,6 +430,7 @@ describe('Test plugin filter hooks', function () {
430 430
431 describe('Download hooks', function () { 431 describe('Download hooks', function () {
432 const downloadVideos: VideoDetails[] = [] 432 const downloadVideos: VideoDetails[] = []
433 let downloadVideo2Token: string
433 434
434 before(async function () { 435 before(async function () {
435 this.timeout(120000) 436 this.timeout(120000)
@@ -459,6 +460,8 @@ describe('Test plugin filter hooks', function () {
459 for (const uuid of uuids) { 460 for (const uuid of uuids) {
460 downloadVideos.push(await servers[0].videos.get({ id: uuid })) 461 downloadVideos.push(await servers[0].videos.get({ id: uuid }))
461 } 462 }
463
464 downloadVideo2Token = await servers[0].videoToken.getVideoFileToken({ videoId: downloadVideos[2].uuid })
462 }) 465 })
463 466
464 it('Should run filter:api.download.torrent.allowed.result', async function () { 467 it('Should run filter:api.download.torrent.allowed.result', async function () {
@@ -471,32 +474,42 @@ describe('Test plugin filter hooks', function () {
471 474
472 it('Should run filter:api.download.video.allowed.result', async function () { 475 it('Should run filter:api.download.video.allowed.result', async function () {
473 { 476 {
474 const res = await makeRawRequest({ url: downloadVideos[1].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 477 const refused = downloadVideos[1].files[0].fileDownloadUrl
478 const allowed = [
479 downloadVideos[0].files[0].fileDownloadUrl,
480 downloadVideos[2].files[0].fileDownloadUrl
481 ]
482
483 const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
475 expect(res.body.error).to.equal('Cao Cao') 484 expect(res.body.error).to.equal('Cao Cao')
476 485
477 await makeRawRequest({ url: downloadVideos[0].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 486 for (const url of allowed) {
478 await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 487 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
488 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
489 }
479 } 490 }
480 491
481 { 492 {
482 const res = await makeRawRequest({ 493 const refused = downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl
483 url: downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl,
484 expectedStatus: HttpStatusCode.FORBIDDEN_403
485 })
486 494
487 expect(res.body.error).to.equal('Sun Jian') 495 const allowed = [
496 downloadVideos[2].files[0].fileDownloadUrl,
497 downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
498 downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl
499 ]
488 500
489 await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 501 // Only streaming playlist is refuse
502 const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
503 expect(res.body.error).to.equal('Sun Jian')
490 504
491 await makeRawRequest({ 505 // But not we there is a user in res
492 url: downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, 506 await makeRawRequest({ url: refused, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 })
493 expectedStatus: HttpStatusCode.OK_200 507 await makeRawRequest({ url: refused, query: { videoFileToken: downloadVideo2Token }, expectedStatus: HttpStatusCode.OK_200 })
494 })
495 508
496 await makeRawRequest({ 509 // Other files work
497 url: downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl, 510 for (const url of allowed) {
498 expectedStatus: HttpStatusCode.OK_200 511 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
499 }) 512 }
500 } 513 }
501 }) 514 })
502 }) 515 })
diff --git a/server/tests/plugins/id-and-pass-auth.ts b/server/tests/plugins/id-and-pass-auth.ts
index fc24a5656..10155c28b 100644
--- a/server/tests/plugins/id-and-pass-auth.ts
+++ b/server/tests/plugins/id-and-pass-auth.ts
@@ -13,6 +13,7 @@ describe('Test id and pass auth plugins', function () {
13 13
14 let lagunaAccessToken: string 14 let lagunaAccessToken: string
15 let lagunaRefreshToken: string 15 let lagunaRefreshToken: string
16 let lagunaId: number
16 17
17 before(async function () { 18 before(async function () {
18 this.timeout(30000) 19 this.timeout(30000)
@@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () {
78 const body = await server.users.getMyInfo({ token: lagunaAccessToken }) 79 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
79 80
80 expect(body.username).to.equal('laguna') 81 expect(body.username).to.equal('laguna')
81 expect(body.account.displayName).to.equal('laguna') 82 expect(body.account.displayName).to.equal('Laguna Loire')
82 expect(body.role.id).to.equal(UserRole.USER) 83 expect(body.role.id).to.equal(UserRole.USER)
84
85 lagunaId = body.id
83 } 86 }
84 }) 87 })
85 88
@@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () {
132 expect(body.role.id).to.equal(UserRole.MODERATOR) 135 expect(body.role.id).to.equal(UserRole.MODERATOR)
133 }) 136 })
134 137
138 it('Should login Laguna and update the profile', async function () {
139 {
140 await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 })
141 await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' })
142
143 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
144 expect(body.username).to.equal('laguna')
145 expect(body.account.displayName).to.equal('laguna updated')
146 expect(body.videoQuota).to.equal(43000)
147 expect(body.videoQuotaDaily).to.equal(43100)
148 }
149
150 {
151 const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } })
152 lagunaAccessToken = body.access_token
153 lagunaRefreshToken = body.refresh_token
154 }
155
156 {
157 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
158 expect(body.username).to.equal('laguna')
159 expect(body.account.displayName).to.equal('Laguna Loire')
160 expect(body.videoQuota).to.equal(42000)
161 expect(body.videoQuotaDaily).to.equal(43100)
162 }
163 })
164
135 it('Should reject token of laguna by the plugin hook', async function () { 165 it('Should reject token of laguna by the plugin hook', async function () {
136 this.timeout(10000) 166 this.timeout(10000)
137 167
@@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () {
147 await server.servers.waitUntilLog('valid username') 177 await server.servers.waitUntilLog('valid username')
148 178
149 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 179 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
150 await server.servers.waitUntilLog('valid display name') 180 await server.servers.waitUntilLog('valid displayName')
151 181
152 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 182 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
153 await server.servers.waitUntilLog('valid role') 183 await server.servers.waitUntilLog('valid role')
diff --git a/server/tests/plugins/plugin-helpers.ts b/server/tests/plugins/plugin-helpers.ts
index 038e3f0d6..e25992723 100644
--- a/server/tests/plugins/plugin-helpers.ts
+++ b/server/tests/plugins/plugin-helpers.ts
@@ -64,6 +64,18 @@ describe('Test plugin helpers', function () {
64 await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`) 64 await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`)
65 }) 65 })
66 66
67 it('Should have the correct listening config', async function () {
68 const res = await makeGetRequest({
69 url: servers[0].url,
70 path: '/plugins/test-four/router/server-listening-config',
71 expectedStatus: HttpStatusCode.OK_200
72 })
73
74 expect(res.body.config).to.exist
75 expect(res.body.config.hostname).to.equal('::')
76 expect(res.body.config.port).to.equal(servers[0].port)
77 })
78
67 it('Should have the correct config', async function () { 79 it('Should have the correct config', async function () {
68 const res = await makeGetRequest({ 80 const res = await makeGetRequest({
69 url: servers[0].url, 81 url: servers[0].url,
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 3738ffc47..6fea4dac2 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -1,4 +1,3 @@
1
2import { OutgoingHttpHeaders } from 'http' 1import { OutgoingHttpHeaders } from 'http'
3import { RegisterServerAuthExternalOptions } from '@server/types' 2import { RegisterServerAuthExternalOptions } from '@server/types'
4import { 3import {
@@ -10,6 +9,7 @@ import {
10 MChannelBannerAccountDefault, 9 MChannelBannerAccountDefault,
11 MChannelSyncChannel, 10 MChannelSyncChannel,
12 MStreamingPlaylist, 11 MStreamingPlaylist,
12 MUserAccountUrl,
13 MVideoChangeOwnershipFull, 13 MVideoChangeOwnershipFull,
14 MVideoFile, 14 MVideoFile,
15 MVideoFormattableDetails, 15 MVideoFormattableDetails,
@@ -187,6 +187,10 @@ declare module 'express' {
187 actor: MActorAccountChannelId 187 actor: MActorAccountChannelId
188 } 188 }
189 189
190 videoFileToken?: {
191 user: MUserAccountUrl
192 }
193
190 authenticated?: boolean 194 authenticated?: boolean
191 195
192 registeredPlugin?: RegisteredPlugin 196 registeredPlugin?: RegisteredPlugin
diff --git a/server/types/lib.d.ts b/server/types/lib.d.ts
new file mode 100644
index 000000000..c901e2032
--- /dev/null
+++ b/server/types/lib.d.ts
@@ -0,0 +1,12 @@
1type ObjectKeys<T> =
2 T extends object
3 ? `${Exclude<keyof T, symbol>}`[]
4 : T extends number
5 ? []
6 : T extends any | string
7 ? string[]
8 : never
9
10interface ObjectConstructor {
11 keys<T> (o: T): ObjectKeys<T>
12}
diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts
index 79c18c406..e10968c20 100644
--- a/server/types/plugins/register-server-auth.model.ts
+++ b/server/types/plugins/register-server-auth.model.ts
@@ -1,14 +1,33 @@
1import express from 'express' 1import express from 'express'
2import { UserRole } from '@shared/models' 2import { UserAdminFlag, UserRole } from '@shared/models'
3import { MOAuthToken, MUser } from '../models' 3import { MOAuthToken, MUser } from '../models'
4 4
5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions 5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
6 6
7export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily'
8
7export interface RegisterServerAuthenticatedResult { 9export interface RegisterServerAuthenticatedResult {
10 // Update the user profile if it already exists
11 // Default behaviour is no update
12 // Introduced in PeerTube >= 5.1
13 userUpdater?: <T> (options: {
14 fieldName: AuthenticatedResultUpdaterFieldName
15 currentValue: T
16 newValue: T
17 }) => T
18
8 username: string 19 username: string
9 email: string 20 email: string
10 role?: UserRole 21 role?: UserRole
11 displayName?: string 22 displayName?: string
23
24 // PeerTube >= 5.1
25 adminFlags?: UserAdminFlag
26
27 // PeerTube >= 5.1
28 videoQuota?: number
29 // PeerTube >= 5.1
30 videoQuotaDaily?: number
12} 31}
13 32
14export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult { 33export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
diff --git a/server/types/plugins/register-server-option.model.ts b/server/types/plugins/register-server-option.model.ts
index 1e2bd830e..df419fff4 100644
--- a/server/types/plugins/register-server-option.model.ts
+++ b/server/types/plugins/register-server-option.model.ts
@@ -71,6 +71,9 @@ export type PeerTubeHelpers = {
71 config: { 71 config: {
72 getWebserverUrl: () => string 72 getWebserverUrl: () => string
73 73
74 // PeerTube >= 5.1
75 getServerListeningConfig: () => { hostname: string, port: number }
76
74 getServerConfig: () => Promise<ServerConfig> 77 getServerConfig: () => Promise<ServerConfig>
75 } 78 }
76 79
diff --git a/shared/core-utils/plugins/hooks.ts b/shared/core-utils/plugins/hooks.ts
index 3784969b5..96bcc945e 100644
--- a/shared/core-utils/plugins/hooks.ts
+++ b/shared/core-utils/plugins/hooks.ts
@@ -1,3 +1,4 @@
1import { RegisteredExternalAuthConfig } from '@shared/models'
1import { HookType } from '../../models/plugins/hook-type.enum' 2import { HookType } from '../../models/plugins/hook-type.enum'
2import { isCatchable, isPromise } from '../common/promises' 3import { isCatchable, isPromise } from '../common/promises'
3 4
@@ -49,7 +50,12 @@ async function internalRunHook <T> (options: {
49 return result 50 return result
50} 51}
51 52
53function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) {
54 return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
55}
56
52export { 57export {
53 getHookType, 58 getHookType,
54 internalRunHook 59 internalRunHook,
60 getExternalAuthHref
55} 61}
diff --git a/shared/server-commands/miscs/sql-command.ts b/shared/server-commands/miscs/sql-command.ts
index 823fc9e38..35cc2253f 100644
--- a/shared/server-commands/miscs/sql-command.ts
+++ b/shared/server-commands/miscs/sql-command.ts
@@ -13,101 +13,87 @@ export class SQLCommand extends AbstractCommand {
13 return seq.query(`DELETE FROM "${table}"`, options) 13 return seq.query(`DELETE FROM "${table}"`, options)
14 } 14 }
15 15
16 async getCount (table: string) { 16 async getVideoShareCount () {
17 const seq = this.getSequelize() 17 const [ { total } ] = await this.selectQuery<{ total: string }>(`SELECT COUNT(*) as total FROM "videoShare"`)
18
19 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
20
21 const [ { total } ] = await seq.query<{ total: string }>(`SELECT COUNT(*) as total FROM "${table}"`, options)
22 if (total === null) return 0 18 if (total === null) return 0
23 19
24 return parseInt(total, 10) 20 return parseInt(total, 10)
25 } 21 }
26 22
27 async getInternalFileUrl (fileId: number) { 23 async getInternalFileUrl (fileId: number) {
28 return this.selectQuery(`SELECT "fileUrl" FROM "videoFile" WHERE id = ${fileId}`) 24 return this.selectQuery<{ fileUrl: string }>(`SELECT "fileUrl" FROM "videoFile" WHERE id = :fileId`, { fileId })
29 .then(rows => rows[0].fileUrl as string) 25 .then(rows => rows[0].fileUrl)
30 } 26 }
31 27
32 setActorField (to: string, field: string, value: string) { 28 setActorField (to: string, field: string, value: string) {
33 const seq = this.getSequelize() 29 return this.updateQuery(`UPDATE actor SET ${this.escapeColumnName(field)} = :value WHERE url = :to`, { value, to })
34
35 const options = { type: QueryTypes.UPDATE }
36
37 return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options)
38 } 30 }
39 31
40 setVideoField (uuid: string, field: string, value: string) { 32 setVideoField (uuid: string, field: string, value: string) {
41 const seq = this.getSequelize() 33 return this.updateQuery(`UPDATE video SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
42
43 const options = { type: QueryTypes.UPDATE }
44
45 return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
46 } 34 }
47 35
48 setPlaylistField (uuid: string, field: string, value: string) { 36 setPlaylistField (uuid: string, field: string, value: string) {
49 const seq = this.getSequelize() 37 return this.updateQuery(`UPDATE "videoPlaylist" SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
50
51 const options = { type: QueryTypes.UPDATE }
52
53 return seq.query(`UPDATE "videoPlaylist" SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
54 } 38 }
55 39
56 async countVideoViewsOf (uuid: string) { 40 async countVideoViewsOf (uuid: string) {
57 const seq = this.getSequelize()
58
59 const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' + 41 const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' +
60 `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = '${uuid}'` 42 `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = :uuid`
61
62 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
63 const [ { total } ] = await seq.query<{ total: number }>(query, options)
64 43
44 const [ { total } ] = await this.selectQuery<{ total: number }>(query, { uuid })
65 if (!total) return 0 45 if (!total) return 0
66 46
67 return forceNumber(total) 47 return forceNumber(total)
68 } 48 }
69 49
70 getActorImage (filename: string) { 50 getActorImage (filename: string) {
71 return this.selectQuery(`SELECT * FROM "actorImage" WHERE filename = '${filename}'`) 51 return this.selectQuery<{ width: number, height: number }>(`SELECT * FROM "actorImage" WHERE filename = :filename`, { filename })
72 .then(rows => rows[0]) 52 .then(rows => rows[0])
73 } 53 }
74 54
75 selectQuery (query: string) { 55 // ---------------------------------------------------------------------------
76 const seq = this.getSequelize()
77 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
78 56
79 return seq.query<any>(query, options) 57 setPluginVersion (pluginName: string, newVersion: string) {
58 return this.setPluginField(pluginName, 'version', newVersion)
80 } 59 }
81 60
82 updateQuery (query: string) { 61 setPluginLatestVersion (pluginName: string, newVersion: string) {
83 const seq = this.getSequelize() 62 return this.setPluginField(pluginName, 'latestVersion', newVersion)
84 const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE } 63 }
85 64
86 return seq.query(query, options) 65 setPluginField (pluginName: string, field: string, value: string) {
66 return this.updateQuery(
67 `UPDATE "plugin" SET ${this.escapeColumnName(field)} = :value WHERE "name" = :pluginName`,
68 { pluginName, value }
69 )
87 } 70 }
88 71
89 // --------------------------------------------------------------------------- 72 // ---------------------------------------------------------------------------
90 73
91 setPluginField (pluginName: string, field: string, value: string) { 74 selectQuery <T extends object> (query: string, replacements: { [id: string]: string | number } = {}) {
92 const seq = this.getSequelize() 75 const seq = this.getSequelize()
76 const options = {
77 type: QueryTypes.SELECT as QueryTypes.SELECT,
78 replacements
79 }
93 80
94 const options = { type: QueryTypes.UPDATE } 81 return seq.query<T>(query, options)
95
96 return seq.query(`UPDATE "plugin" SET "${field}" = '${value}' WHERE "name" = '${pluginName}'`, options)
97 } 82 }
98 83
99 setPluginVersion (pluginName: string, newVersion: string) { 84 updateQuery (query: string, replacements: { [id: string]: string | number } = {}) {
100 return this.setPluginField(pluginName, 'version', newVersion) 85 const seq = this.getSequelize()
101 } 86 const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE, replacements }
102 87
103 setPluginLatestVersion (pluginName: string, newVersion: string) { 88 return seq.query(query, options)
104 return this.setPluginField(pluginName, 'latestVersion', newVersion)
105 } 89 }
106 90
107 // --------------------------------------------------------------------------- 91 // ---------------------------------------------------------------------------
108 92
109 async getPlaylistInfohash (playlistId: number) { 93 async getPlaylistInfohash (playlistId: number) {
110 const result = await this.selectQuery('SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = ' + playlistId) 94 const query = 'SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = :playlistId'
95
96 const result = await this.selectQuery<{ p2pMediaLoaderInfohashes: string }>(query, { playlistId })
111 if (!result || result.length === 0) return [] 97 if (!result || result.length === 0) return []
112 98
113 return result[0].p2pMediaLoaderInfohashes 99 return result[0].p2pMediaLoaderInfohashes
@@ -116,19 +102,14 @@ export class SQLCommand extends AbstractCommand {
116 // --------------------------------------------------------------------------- 102 // ---------------------------------------------------------------------------
117 103
118 setActorFollowScores (newScore: number) { 104 setActorFollowScores (newScore: number) {
119 const seq = this.getSequelize() 105 return this.updateQuery(`UPDATE "actorFollow" SET "score" = :newScore`, { newScore })
120
121 const options = { type: QueryTypes.UPDATE }
122
123 return seq.query(`UPDATE "actorFollow" SET "score" = ${newScore}`, options)
124 } 106 }
125 107
126 setTokenField (accessToken: string, field: string, value: string) { 108 setTokenField (accessToken: string, field: string, value: string) {
127 const seq = this.getSequelize() 109 return this.updateQuery(
128 110 `UPDATE "oAuthToken" SET ${this.escapeColumnName(field)} = :value WHERE "accessToken" = :accessToken`,
129 const options = { type: QueryTypes.UPDATE } 111 { value, accessToken }
130 112 )
131 return seq.query(`UPDATE "oAuthToken" SET "${field}" = '${value}' WHERE "accessToken" = '${accessToken}'`, options)
132 } 113 }
133 114
134 async cleanup () { 115 async cleanup () {
@@ -157,4 +138,9 @@ export class SQLCommand extends AbstractCommand {
157 return this.sequelize 138 return this.sequelize
158 } 139 }
159 140
141 private escapeColumnName (columnName: string) {
142 return this.getSequelize().escape(columnName)
143 .replace(/^'/, '"')
144 .replace(/'$/, '"')
145 }
160} 146}
diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts
index dc9cf4e01..cb0e1a5fb 100644
--- a/shared/server-commands/requests/requests.ts
+++ b/shared/server-commands/requests/requests.ts
@@ -199,7 +199,7 @@ function buildRequest (req: request.Test, options: CommonRequestParams) {
199 return req.expect((res) => { 199 return req.expect((res) => {
200 if (options.expectedStatus && res.status !== options.expectedStatus) { 200 if (options.expectedStatus && res.status !== options.expectedStatus) {
201 throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` + 201 throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` +
202 `\nThe server responded this error: "${res.body?.error ?? res.text}".\n` + 202 `\nThe server responded: "${res.body?.error ?? res.text}".\n` +
203 'You may take a closer look at the logs. To see how to do so, check out this page: ' + 203 'You may take a closer look at the logs. To see how to do so, check out this page: ' +
204 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs') 204 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs')
205 } 205 }
diff --git a/support/doc/dependencies.md b/support/doc/dependencies.md
index bf53b8080..5cf1d5879 100644
--- a/support/doc/dependencies.md
+++ b/support/doc/dependencies.md
@@ -2,8 +2,6 @@
2 2
3:warning: **Warning**: dependencies guide is maintained by the community. Some parts may be outdated! :warning: 3:warning: **Warning**: dependencies guide is maintained by the community. Some parts may be outdated! :warning:
4 4
5Follow the below guides, and check their versions match [required external dependencies versions](https://github.com/Chocobozzz/PeerTube/blob/master/engines.yaml).
6
7Main dependencies version supported by PeerTube: 5Main dependencies version supported by PeerTube:
8 6
9 * `node` >=14.x 7 * `node` >=14.x
diff --git a/support/doc/docker.md b/support/doc/docker.md
index 267863a4d..b6990f3e3 100644
--- a/support/doc/docker.md
+++ b/support/doc/docker.md
@@ -120,7 +120,7 @@ See the production guide ["What now" section](https://docs.joinpeertube.org/inst
120 120
121## Upgrade 121## Upgrade
122 122
123**Important:** Before upgrading, check you have all the `storage` fields in your [production.yaml file](https://github.com/Chocobozzz/PeerTube/blob/master/support/docker/production/config/production.yaml). 123**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
124 124
125Pull the latest images: 125Pull the latest images:
126 126
diff --git a/support/doc/plugins/guide.md b/support/doc/plugins/guide.md
index a1131ced5..9ddab3ece 100644
--- a/support/doc/plugins/guide.md
+++ b/support/doc/plugins/guide.md
@@ -433,7 +433,27 @@ function register (...) {
433 username: 'user' 433 username: 'user'
434 email: 'user@example.com' 434 email: 'user@example.com'
435 role: 2 435 role: 2
436 displayName: 'User display name' 436 displayName: 'User display name',
437
438 // Custom admin flags (bypass video auto moderation etc.)
439 // https://github.com/Chocobozzz/PeerTube/blob/develop/shared/models/users/user-flag.model.ts
440 // PeerTube >= 5.1
441 adminFlags: 0,
442 // Quota in bytes
443 // PeerTube >= 5.1
444 videoQuota: 1024 * 1024 * 1024, // 1GB
445 // PeerTube >= 5.1
446 videoQuotaDaily: -1, // Unlimited
447
448 // Update the user profile if it already exists
449 // Default behaviour is no update
450 // Introduced in PeerTube >= 5.1
451 userUpdater: ({ fieldName, currentValue, newValue }) => {
452 // Always use new value except for videoQuotaDaily field
453 if (fieldName === 'videoQuotaDaily') return currentValue
454
455 return newValue
456 }
437 }) 457 })
438 }) 458 })
439 459
diff --git a/support/doc/production.md b/support/doc/production.md
index dd57e9120..9a84f19a3 100644
--- a/support/doc/production.md
+++ b/support/doc/production.md
@@ -177,16 +177,17 @@ $ sudo vim /etc/letsencrypt/renewal/your-domain.com.conf
177 177
178If you plan to have many concurrent viewers on your PeerTube instance, consider increasing `worker_connections` value: https://nginx.org/en/docs/ngx_core_module.html#worker_connections. 178If you plan to have many concurrent viewers on your PeerTube instance, consider increasing `worker_connections` value: https://nginx.org/en/docs/ngx_core_module.html#worker_connections.
179 179
180**FreeBSD** 180<details>
181<summary><strong>If using FreeBSD</strong></summary>
182
181On FreeBSD you can use [Dehydrated](https://dehydrated.io/) `security/dehydrated` for [Let's Encrypt](https://letsencrypt.org/) 183On FreeBSD you can use [Dehydrated](https://dehydrated.io/) `security/dehydrated` for [Let's Encrypt](https://letsencrypt.org/)
182 184
183```bash 185```bash
184$ sudo pkg install dehydrated 186$ sudo pkg install dehydrated
185``` 187```
188</details>
186 189
187### :alembic: TCP/IP Tuning 190### :alembic: Linux TCP/IP Tuning
188
189**On Linux**
190 191
191```bash 192```bash
192$ sudo cp /var/www/peertube/peertube-latest/support/sysctl.d/30-peertube-tcp.conf /etc/sysctl.d/ 193$ sudo cp /var/www/peertube/peertube-latest/support/sysctl.d/30-peertube-tcp.conf /etc/sysctl.d/
@@ -231,7 +232,9 @@ $ sudo systemctl start peertube
231$ sudo journalctl -feu peertube 232$ sudo journalctl -feu peertube
232``` 233```
233 234
234**FreeBSD** 235<details>
236<summary><strong>If using FreeBSD</strong></summary>
237
235On FreeBSD, copy the startup script and update rc.conf: 238On FreeBSD, copy the startup script and update rc.conf:
236 239
237```bash 240```bash
@@ -244,8 +247,10 @@ Run:
244```bash 247```bash
245$ sudo service peertube start 248$ sudo service peertube start
246``` 249```
250</details>
247 251
248### :bricks: OpenRC 252<details>
253<summary><strong>If using OpenRC</strong></summary>
249 254
250If your OS uses OpenRC, copy the service script: 255If your OS uses OpenRC, copy the service script:
251 256
@@ -265,6 +270,7 @@ Run and print last logs:
265$ sudo /etc/init.d/peertube start 270$ sudo /etc/init.d/peertube start
266$ tail -f /var/log/peertube/peertube.log 271$ tail -f /var/log/peertube/peertube.log
267``` 272```
273</details>
268 274
269### :technologist: Administrator 275### :technologist: Administrator
270 276
@@ -291,16 +297,15 @@ Now your instance is up you can:
291 297
292**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md 298**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
293 299
294#### Auto 300Run the upgrade script (the password it asks is PeerTube's database user password):
295
296The password it asks is PeerTube's database user password.
297 301
298```bash 302```bash
299$ cd /var/www/peertube/peertube-latest/scripts && sudo -H -u peertube ./upgrade.sh 303$ cd /var/www/peertube/peertube-latest/scripts && sudo -H -u peertube ./upgrade.sh
300$ sudo systemctl restart peertube # Or use your OS command to restart PeerTube if you don't use systemd 304$ sudo systemctl restart peertube # Or use your OS command to restart PeerTube if you don't use systemd
301``` 305```
302 306
303#### Manually 307<details>
308<summary><strong>Prefer manual upgrade?</strong></summary>
304 309
305Make a SQL backup 310Make a SQL backup
306 311
@@ -346,17 +351,18 @@ $ cd /var/www/peertube && \
346 sudo unlink ./peertube-latest && \ 351 sudo unlink ./peertube-latest && \
347 sudo -u peertube ln -s versions/peertube-${VERSION} ./peertube-latest 352 sudo -u peertube ln -s versions/peertube-${VERSION} ./peertube-latest
348``` 353```
354</details>
349 355
350### Configuration 356### Update PeerTube configuration
351 357
352You can check for configuration changes, and report them in your `config/production.yaml` file: 358Check for configuration changes, and report them in your `config/production.yaml` file:
353 359
354```bash 360```bash
355$ cd /var/www/peertube/versions 361$ cd /var/www/peertube/versions
356$ diff -u "$(ls --sort=t | head -2 | tail -1)/config/production.yaml.example" "$(ls --sort=t | head -1)/config/production.yaml.example" 362$ diff -u "$(ls --sort=t | head -2 | tail -1)/config/production.yaml.example" "$(ls --sort=t | head -1)/config/production.yaml.example"
357``` 363```
358 364
359### nginx 365### Update nginx configuration
360 366
361Check changes in nginx configuration: 367Check changes in nginx configuration:
362 368
@@ -365,7 +371,7 @@ $ cd /var/www/peertube/versions
365$ diff -u "$(ls --sort=t | head -2 | tail -1)/support/nginx/peertube" "$(ls --sort=t | head -1)/support/nginx/peertube" 371$ diff -u "$(ls --sort=t | head -2 | tail -1)/support/nginx/peertube" "$(ls --sort=t | head -1)/support/nginx/peertube"
366``` 372```
367 373
368### systemd 374### Update systemd service
369 375
370Check changes in systemd configuration: 376Check changes in systemd configuration:
371 377
diff --git a/tsconfig.json b/tsconfig.json
index 993acf81d..8bcd944e3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,7 +8,6 @@
8 "@shared/*": [ "shared/*" ] 8 "@shared/*": [ "shared/*" ]
9 }, 9 },
10 "typeRoots": [ 10 "typeRoots": [
11 "server/typings",
12 "node_modules/@types" 11 "node_modules/@types"
13 ] 12 ]
14 }, 13 },
@@ -17,5 +16,5 @@
17 { "path": "./server" }, 16 { "path": "./server" },
18 { "path": "./scripts" } 17 { "path": "./scripts" }
19 ], 18 ],
20 "files": [ "server.ts", "server/types/express.d.ts" ] 19 "files": [ "server.ts", "server/types/express.d.ts", "server/types/lib.d.ts" ]
21} 20}
diff --git a/yarn.lock b/yarn.lock
index 4093a87fd..d9541b4d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9099,7 +9099,7 @@ typedarray@^0.0.6:
9099 resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" 9099 resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
9100 integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== 9100 integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
9101 9101
9102typescript@^4.0.5: 9102typescript@~4.8:
9103 version "4.8.4" 9103 version "4.8.4"
9104 resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" 9104 resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
9105 integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== 9105 integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==