From 4beda9e12adc7b1f3b178cecd6863ebf3cf431f1 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 19 Oct 2021 09:44:43 +0200 Subject: [PATCH] Add ability to view my followers --- .../about-follows/about-follows.component.ts | 4 +- .../src/app/+accounts/accounts.component.scss | 2 +- .../edit-basic-configuration.component.html | 2 +- .../plugins/shared/plugin-api.service.ts | 4 +- .../my-video-channels.component.html | 7 +- .../my-video-channels.component.scss | 4 + .../my-follows/my-followers.component.html | 31 ++++ .../my-follows/my-followers.component.scss | 26 +++ .../my-follows/my-followers.component.ts | 76 +++++++++ .../my-subscriptions.component.html | 14 +- .../my-subscriptions.component.scss | 16 ++ .../my-subscriptions.component.ts | 0 .../+my-library/my-library-routing.module.ts | 12 +- .../app/+my-library/my-library.component.ts | 15 +- .../src/app/+my-library/my-library.module.ts | 6 +- .../my-subscriptions.component.scss | 84 ---------- .../video-channels.component.scss | 2 +- client/src/app/core/rest/rest.service.ts | 16 +- .../custom-markup-help.component.html | 2 +- .../advanced-input-filter.component.ts | 2 + .../shared-main/users/user-history.service.ts | 2 +- .../users/user-notification.service.ts | 2 +- .../users/user-notifications.component.html | 2 +- .../video-channel/video-channel.service.ts | 2 +- .../shared/shared-main/video/video.service.ts | 4 +- .../shared/shared-search/search.service.ts | 6 +- .../user-subscription.service.ts | 41 ++++- .../video-comment.service.ts | 2 +- .../video-playlist.service.ts | 6 +- .../sass/include/_account-channel-page.scss | 88 ++++++++++ client/src/sass/include/_actor.scss | 104 +++++------- client/src/sass/include/_mixins.scss | 9 +- server/controllers/api/accounts.ts | 33 +++- server/controllers/api/server/follows.ts | 4 +- .../controllers/api/users/my-subscriptions.ts | 2 +- server/controllers/api/video-channel.ts | 55 ++++-- server/initializers/constants.ts | 3 + server/middlewares/validators/sort.ts | 5 + server/middlewares/validators/users.ts | 22 ++- .../validators/videos/video-channels.ts | 16 -- server/models/actor/actor-follow.ts | 22 +-- server/models/video/video-channel.ts | 24 ++- server/tests/api/check-params/users.ts | 28 ++++ .../tests/api/check-params/video-channels.ts | 28 ++++ server/tests/api/users/user-subscriptions.ts | 156 ++++++++++++++++++ shared/extra-utils/users/accounts-command.ts | 24 ++- shared/extra-utils/videos/channels-command.ts | 26 ++- 47 files changed, 796 insertions(+), 245 deletions(-) create mode 100644 client/src/app/+my-library/my-follows/my-followers.component.html create mode 100644 client/src/app/+my-library/my-follows/my-followers.component.scss create mode 100644 client/src/app/+my-library/my-follows/my-followers.component.ts rename client/src/app/+my-library/{my-subscriptions => my-follows}/my-subscriptions.component.html (65%) create mode 100644 client/src/app/+my-library/my-follows/my-subscriptions.component.scss rename client/src/app/+my-library/{my-subscriptions => my-follows}/my-subscriptions.component.ts (100%) delete mode 100644 client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss create mode 100644 client/src/sass/include/_account-channel-page.scss diff --git a/client/src/app/+about/about-follows/about-follows.component.ts b/client/src/app/+about/about-follows/about-follows.component.ts index a35272681..84b47e967 100644 --- a/client/src/app/+about/about-follows/about-follows.component.ts +++ b/client/src/app/+about/about-follows/about-follows.component.ts @@ -88,7 +88,7 @@ export class AboutFollowsComponent implements OnInit { } private loadMoreFollowers (reset = false) { - const pagination = this.restService.componentPaginationToRestPagination(this.followersPagination) + const pagination = this.restService.componentToRestPagination(this.followersPagination) this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' }) .subscribe({ @@ -106,7 +106,7 @@ export class AboutFollowsComponent implements OnInit { } private loadMoreFollowings (reset = false) { - const pagination = this.restService.componentPaginationToRestPagination(this.followingsPagination) + const pagination = this.restService.componentToRestPagination(this.followingsPagination) this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' }) .subscribe({ diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss index c4e2159d1..cdd00487b 100644 --- a/client/src/app/+accounts/accounts.component.scss +++ b/client/src/app/+accounts/accounts.component.scss @@ -1,6 +1,6 @@ @use '_variables' as *; @use '_mixins' as *; -@use '_actor' as *; +@use '_account-channel-page' as *; @use '_miniature' as *; .root { 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 1f542e458..537e06d4d 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 @@ -416,7 +416,7 @@

⚠️ This functionality requires a lot of attention and extra moderation.

- See the documentation for more information about the expected URL + See the documentation for more information about the expected URL diff --git a/client/src/app/+admin/plugins/shared/plugin-api.service.ts b/client/src/app/+admin/plugins/shared/plugin-api.service.ts index c4f480cae..b95ee0c9d 100644 --- a/client/src/app/+admin/plugins/shared/plugin-api.service.ts +++ b/client/src/app/+admin/plugins/shared/plugin-api.service.ts @@ -51,7 +51,7 @@ export class PluginApiService { componentPagination: ComponentPagination, sort: string ) { - const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + const pagination = this.restService.componentToRestPagination(componentPagination) let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) @@ -67,7 +67,7 @@ export class PluginApiService { sort: string, search?: string ) { - const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + const pagination = this.restService.componentToRestPagination(componentPagination) let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html index 4c5b46d5b..bbe583971 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html +++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html @@ -27,7 +27,12 @@
{{ videoChannel.nameWithHost }}
-
{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}
+ + {videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}} +
{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss index 9ef5513b6..998e46cb2 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss +++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss @@ -54,6 +54,10 @@ my-edit-button { color: $grey-actor-name; } +.video-channel-followers { + color: pvar(--mainForegroundColor); +} + .video-channel-buttons { margin-top: 10px; min-width: 190px; diff --git a/client/src/app/+my-library/my-follows/my-followers.component.html b/client/src/app/+my-library/my-follows/my-followers.component.html new file mode 100644 index 000000000..d2b2dccb6 --- /dev/null +++ b/client/src/app/+my-library/my-follows/my-followers.component.html @@ -0,0 +1,31 @@ +

+ + + My followers + {{ pagination.totalItems }} + +

+ +
+ +
+ +
No follower found.
+ +
+
+ + +
+ +
{{ follow.follower.name + '@' + follow.follower.host }}
+ +
+ +
+ Is following all your channels + Is following your channel {{ follow.following.name }} +
+
+
+
diff --git a/client/src/app/+my-library/my-follows/my-followers.component.scss b/client/src/app/+my-library/my-follows/my-followers.component.scss new file mode 100644 index 000000000..15b51c419 --- /dev/null +++ b/client/src/app/+my-library/my-follows/my-followers.component.scss @@ -0,0 +1,26 @@ +@use '_variables' as *; +@use '_mixins' as *; +@use '_actor' as *; + +.followers-header { + margin-bottom: 30px; + display: flex; +} + +input[type=text] { + @include peertube-input-text(300px); +} + +.actor { + @include actor-row($avatar-size: 40px, $min-height: auto, $separator: true); + + .actor-display-name { + font-size: 16px; + + + .glyphicon { + @include margin-left(5px); + + font-size: 12px; + } + } +} diff --git a/client/src/app/+my-library/my-follows/my-followers.component.ts b/client/src/app/+my-library/my-follows/my-followers.component.ts new file mode 100644 index 000000000..a7bbe6d99 --- /dev/null +++ b/client/src/app/+my-library/my-follows/my-followers.component.ts @@ -0,0 +1,76 @@ +import { Subject } from 'rxjs' +import { Component, OnInit } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { AuthService, ComponentPagination, Notifier } from '@app/core' +import { UserSubscriptionService } from '@app/shared/shared-user-subscription' +import { ActorFollow } from '@shared/models' + +@Component({ + templateUrl: './my-followers.component.html', + styleUrls: [ './my-followers.component.scss' ] +}) +export class MyFollowersComponent implements OnInit { + follows: ActorFollow[] = [] + + pagination: ComponentPagination = { + currentPage: 1, + itemsPerPage: 10, + totalItems: null + } + + onDataSubject = new Subject() + search: string + + constructor ( + private route: ActivatedRoute, + private auth: AuthService, + private userSubscriptionService: UserSubscriptionService, + private notifier: Notifier + ) {} + + ngOnInit () { + if (this.route.snapshot.queryParams['search']) { + this.search = this.route.snapshot.queryParams['search'] + } + } + + onNearOfBottom () { + // Last page + if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return + + this.pagination.currentPage += 1 + this.loadFollowers() + } + + onSearch (search: string) { + this.search = search + this.loadFollowers(false) + } + + isFollowingAccount (follow: ActorFollow) { + return follow.following.name === this.getUsername() + } + + private loadFollowers (more = true) { + this.userSubscriptionService.listFollowers({ + pagination: this.pagination, + nameWithHost: this.getUsername(), + search: this.search + }).subscribe({ + next: res => { + this.follows = more + ? this.follows.concat(res.data) + : res.data + this.pagination.totalItems = res.total + + this.onDataSubject.next(res.data) + }, + + error: err => this.notifier.error(err.message) + }) + } + + private getUsername () { + return this.auth.getUser().username + } +} diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html b/client/src/app/+my-library/my-follows/my-subscriptions.component.html similarity index 65% rename from client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html rename to client/src/app/+my-library/my-follows/my-subscriptions.component.html index ca5ad794a..775f0e783 100644 --- a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html +++ b/client/src/app/+my-library/my-follows/my-subscriptions.component.html @@ -12,17 +12,17 @@
You don't have any subscription yet.
-
-
+
+
-
- -
{{ videoChannel.displayName }}
-
{{ videoChannel.nameWithHost }}
+
+ +
{{ videoChannel.displayName }}
+
{{ videoChannel.nameWithHost }}
-
{{ videoChannel.followersCount }} subscribers
+
{{ videoChannel.followersCount }} subscribers
Created by {{ videoChannel.ownerBy }} diff --git a/client/src/app/+my-library/my-follows/my-subscriptions.component.scss b/client/src/app/+my-library/my-follows/my-subscriptions.component.scss new file mode 100644 index 000000000..310e11cb0 --- /dev/null +++ b/client/src/app/+my-library/my-follows/my-subscriptions.component.scss @@ -0,0 +1,16 @@ +@use '_variables' as *; +@use '_mixins' as *; +@use '_actor' as *; + +.video-subscriptions-header { + margin-bottom: 30px; + display: flex; +} + +input[type=text] { + @include peertube-input-text(300px); +} + +.actor { + @include actor-row; +} diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.ts b/client/src/app/+my-library/my-follows/my-subscriptions.component.ts similarity index 100% rename from client/src/app/+my-library/my-subscriptions/my-subscriptions.component.ts rename to client/src/app/+my-library/my-follows/my-subscriptions.component.ts diff --git a/client/src/app/+my-library/my-library-routing.module.ts b/client/src/app/+my-library/my-library-routing.module.ts index 76894bed8..73858fb82 100644 --- a/client/src/app/+my-library/my-library-routing.module.ts +++ b/client/src/app/+my-library/my-library-routing.module.ts @@ -1,10 +1,11 @@ import { NgModule } from '@angular/core' import { RouterModule, Routes } from '@angular/router' import { LoginGuard } from '../core' +import { MyFollowersComponent } from './my-follows/my-followers.component' +import { MySubscriptionsComponent } from './my-follows/my-subscriptions.component' import { MyHistoryComponent } from './my-history/my-history.component' import { MyLibraryComponent } from './my-library.component' import { MyOwnershipComponent } from './my-ownership/my-ownership.component' -import { MySubscriptionsComponent } from './my-subscriptions/my-subscriptions.component' import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component' import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component' import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component' @@ -99,6 +100,15 @@ const myLibraryRoutes: Routes = [ } } }, + { + path: 'followers', + component: MyFollowersComponent, + data: { + meta: { + title: $localize`My followers` + } + } + }, { path: 'ownership', component: MyOwnershipComponent, diff --git a/client/src/app/+my-library/my-library.component.ts b/client/src/app/+my-library/my-library.component.ts index 16a7f63e3..ff901952f 100644 --- a/client/src/app/+my-library/my-library.component.ts +++ b/client/src/app/+my-library/my-library.component.ts @@ -61,8 +61,19 @@ export class MyLibraryComponent implements OnInit { }, { - label: $localize`Subscriptions`, - routerLink: '/my-library/subscriptions' + label: $localize`Follows`, + children: [ + { + label: $localize`Subscriptions`, + iconName: 'subscriptions', + routerLink: '/my-library/subscriptions' + }, + { + label: $localize`Followers`, + iconName: 'follower', + routerLink: '/my-library/followers' + } + ] }, { diff --git a/client/src/app/+my-library/my-library.module.ts b/client/src/app/+my-library/my-library.module.ts index 264ad03f7..360c53589 100644 --- a/client/src/app/+my-library/my-library.module.ts +++ b/client/src/app/+my-library/my-library.module.ts @@ -13,12 +13,13 @@ import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscripti import { SharedVideoLiveModule } from '@app/shared/shared-video-live' import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module' +import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' +import { MySubscriptionsComponent } from './my-follows/my-subscriptions.component' import { MyHistoryComponent } from './my-history/my-history.component' import { MyLibraryRoutingModule } from './my-library-routing.module' import { MyLibraryComponent } from './my-library.component' import { MyAcceptOwnershipComponent } from './my-ownership/my-accept-ownership/my-accept-ownership.component' import { MyOwnershipComponent } from './my-ownership/my-ownership.component' -import { MySubscriptionsComponent } from './my-subscriptions/my-subscriptions.component' import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component' import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component' import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component' @@ -26,7 +27,7 @@ import { MyVideoPlaylistUpdateComponent } from './my-video-playlists/my-video-pl import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlists.component' import { VideoChangeOwnershipComponent } from './my-videos/modals/video-change-ownership.component' import { MyVideosComponent } from './my-videos/my-videos.component' -import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' +import { MyFollowersComponent } from './my-follows/my-followers.component' @NgModule({ imports: [ @@ -61,6 +62,7 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto MyAcceptOwnershipComponent, MyVideoImportsComponent, MySubscriptionsComponent, + MyFollowersComponent, MyHistoryComponent, MyVideoPlaylistCreateComponent, diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss deleted file mode 100644 index edca06a66..000000000 --- a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss +++ /dev/null @@ -1,84 +0,0 @@ -@use '_variables' as *; -@use '_mixins' as *; - -input[type=text] { - @include peertube-input-text(300px); -} - -.video-channel { - @include row-blocks; - - > my-actor-avatar { - @include actor-avatar-size(80px); - - @include margin-right(10px); - } -} - -.video-channel-info { - flex-grow: 1; - - a.video-channel-names { - @include disable-default-a-behaviour; - - width: fit-content; - display: flex; - align-items: baseline; - color: pvar(--mainForegroundColor); - - .video-channel-display-name { - font-weight: $font-semibold; - font-size: 18px; - } - - .video-channel-name { - @include margin-left(5px); - - font-size: 14px; - color: $grey-actor-name; - } - } -} - -.actor-owner { - @include disable-default-a-behaviour; - - font-size: 13px; - color: pvar(--mainForegroundColor); - - span:hover { - opacity: 0.8; - } - - my-actor-avatar { - @include margin-left(7px); - display: inline-block; - vertical-align: top; - } -} - -.video-subscriptions-header { - margin-bottom: 30px; - display: flex; -} - -@media screen and (max-width: $small-view) { - .video-subscriptions-header input[type=text] { - width: 100% !important; - } - - .video-channel-info { - padding-bottom: 10px; - text-align: center; - - .video-channel-names { - flex-direction: column; - align-items: center !important; - margin: auto; - } - } - - img { - @include margin-right(0); - } -} diff --git a/client/src/app/+video-channels/video-channels.component.scss b/client/src/app/+video-channels/video-channels.component.scss index d174dcd62..72ee2d7bb 100644 --- a/client/src/app/+video-channels/video-channels.component.scss +++ b/client/src/app/+video-channels/video-channels.component.scss @@ -1,6 +1,6 @@ @use '_variables' as *; @use '_mixins' as *; -@use '_actor' as *; +@use '_account-channel-page' as *; @use '_miniature' as *; .root { diff --git a/client/src/app/core/rest/rest.service.ts b/client/src/app/core/rest/rest.service.ts index 98e45ffc0..93b5f56b2 100644 --- a/client/src/app/core/rest/rest.service.ts +++ b/client/src/app/core/rest/rest.service.ts @@ -13,9 +13,8 @@ interface QueryStringFilterPrefixes { } } -type ParseQueryStringFilterResult = { - [key: string]: string | number | boolean | (string | number | boolean)[] -} +type ParseQueryStringFilters = Partial> +type ParseQueryStringFiltersResult = ParseQueryStringFilters & { search?: string } @Injectable() export class RestService { @@ -67,14 +66,17 @@ export class RestService { return params } - componentPaginationToRestPagination (componentPagination: ComponentPaginationLight): RestPagination { + componentToRestPagination (componentPagination: ComponentPaginationLight): RestPagination { const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage const count: number = componentPagination.itemsPerPage return { start, count } } - parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes): ParseQueryStringFilterResult { + /* + * Returns an object containing the filters and the remaining search + */ + parseQueryStringFilter (q: string, prefixes: T): ParseQueryStringFiltersResult { if (!q) return {} // Tokenize the strings using spaces that are not in quotes @@ -90,9 +92,9 @@ export class RestService { return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false) }) - const additionalFilters: ParseQueryStringFilterResult = {} + const additionalFilters: ParseQueryStringFilters = {} - for (const prefixKey of Object.keys(prefixes)) { + for (const prefixKey of Object.keys(prefixes) as (keyof T)[]) { const prefixObj = prefixes[prefixKey] const prefix = prefixObj.prefix diff --git a/client/src/app/shared/shared-custom-markup/custom-markup-help.component.html b/client/src/app/shared/shared-custom-markup/custom-markup-help.component.html index dd7a56d7d..0ca84ff78 100644 --- a/client/src/app/shared/shared-custom-markup/custom-markup-help.component.html +++ b/client/src/app/shared/shared-custom-markup/custom-markup-help.component.html @@ -1,3 +1,3 @@ - Markdown compatible that also supports custom PeerTube HTML tags + Markdown compatible that also supports custom PeerTube HTML tags diff --git a/client/src/app/shared/shared-forms/advanced-input-filter.component.ts b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts index 72cd6d460..113219f48 100644 --- a/client/src/app/shared/shared-forms/advanced-input-filter.component.ts +++ b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts @@ -77,6 +77,8 @@ export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { logger('On route search change "%s".', search) + if (this.searchValue === search) return + this.searchValue = search this.emitSearch() }) diff --git a/client/src/app/shared/shared-main/users/user-history.service.ts b/client/src/app/shared/shared-main/users/user-history.service.ts index 91268af8c..a4841897d 100644 --- a/client/src/app/shared/shared-main/users/user-history.service.ts +++ b/client/src/app/shared/shared-main/users/user-history.service.ts @@ -19,7 +19,7 @@ export class UserHistoryService { ) {} getUserVideosHistory (historyPagination: ComponentPaginationLight, search?: string) { - const pagination = this.restService.componentPaginationToRestPagination(historyPagination) + const pagination = this.restService.componentToRestPagination(historyPagination) let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination) diff --git a/client/src/app/shared/shared-main/users/user-notification.service.ts b/client/src/app/shared/shared-main/users/user-notification.service.ts index 09fee87a3..e27dab21a 100644 --- a/client/src/app/shared/shared-main/users/user-notification.service.ts +++ b/client/src/app/shared/shared-main/users/user-notification.service.ts @@ -29,7 +29,7 @@ export class UserNotificationService { const { pagination, ignoreLoadingBar, unread, sort } = parameters let params = new HttpParams() - params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination), sort) + params = this.restService.addRestGetParams(params, this.restService.componentToRestPagination(pagination), sort) if (unread) params = params.append('unread', `${unread}`) diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html index ee8df864a..9af6da784 100644 --- a/client/src/app/shared/shared-main/users/user-notifications.component.html +++ b/client/src/app/shared/shared-main/users/user-notifications.component.html @@ -203,7 +203,7 @@
- A new version of PeerTube is available: {{ notification.peertube.latestVersion }} + A new version of PeerTube is available: {{ notification.peertube.latestVersion }}
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts index 7560a35a8..dc00fabdc 100644 --- a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts +++ b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts @@ -50,7 +50,7 @@ export class VideoChannelService { const { account, componentPagination, withStats = false, sort, search } = options const pagination = componentPagination - ? this.restService.componentPaginationToRestPagination(componentPagination) + ? this.restService.componentToRestPagination(componentPagination) : { start: 0, count: 20 } let params = new HttpParams() diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 3481b116f..2f43f1b9d 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -123,7 +123,7 @@ export class VideoService { } getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable> { - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + const pagination = this.restService.componentToRestPagination(videoPagination) let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) @@ -377,7 +377,7 @@ export class VideoService { private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) { const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + const pagination = this.restService.componentToRestPagination(videoPagination) let newParams = this.restService.addRestGetParams(params, pagination, sort) if (filter) newParams = newParams.set('filter', filter) diff --git a/client/src/app/shared/shared-search/search.service.ts b/client/src/app/shared/shared-search/search.service.ts index fdfab0e0e..71350c733 100644 --- a/client/src/app/shared/shared-search/search.service.ts +++ b/client/src/app/shared/shared-search/search.service.ts @@ -43,7 +43,7 @@ export class SearchService { let pagination: RestPagination if (componentPagination) { - pagination = this.restService.componentPaginationToRestPagination(componentPagination) + pagination = this.restService.componentToRestPagination(componentPagination) } let params = new HttpParams() @@ -77,7 +77,7 @@ export class SearchService { let pagination: RestPagination if (componentPagination) { - pagination = this.restService.componentPaginationToRestPagination(componentPagination) + pagination = this.restService.componentToRestPagination(componentPagination) } let params = new HttpParams() @@ -111,7 +111,7 @@ export class SearchService { let pagination: RestPagination if (componentPagination) { - pagination = this.restService.componentPaginationToRestPagination(componentPagination) + pagination = this.restService.componentToRestPagination(componentPagination) } let params = new HttpParams() diff --git a/client/src/app/shared/shared-user-subscription/user-subscription.service.ts b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts index f289fb6cf..ede65ff39 100644 --- a/client/src/app/shared/shared-user-subscription/user-subscription.service.ts +++ b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts @@ -6,7 +6,7 @@ import { Injectable } from '@angular/core' import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' import { buildBulkObservable } from '@app/helpers' import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' -import { ResultList, VideoChannel as VideoChannelServer, VideoSortField } from '@shared/models' +import { ActorFollow, ResultList, VideoChannel as VideoChannelServer, VideoSortField } from '@shared/models' import { environment } from '../../../environments/environment' const logger = debug('peertube:subscriptions:UserSubscriptionService') @@ -17,6 +17,8 @@ type SubscriptionExistResultObservable = { [ uri: string ]: Observable @Injectable() export class UserSubscriptionService { static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions' + static BASE_VIDEO_CHANNELS_URL = environment.apiUrl + '/api/v1/video-channels' + static BASE_ACCOUNTS_URL = environment.apiUrl + '/api/v1/accounts' // Use a replay subject because we "next" a value before subscribing private existsSubject = new ReplaySubject(1) @@ -43,13 +45,46 @@ export class UserSubscriptionService { ) } + listFollowers (parameters: { + pagination: ComponentPaginationLight + nameWithHost: string + search?: string + }) { + const { pagination, nameWithHost, search } = parameters + + let url = `${UserSubscriptionService.BASE_ACCOUNTS_URL}/${nameWithHost}/followers` + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, this.restService.componentToRestPagination(pagination), '-createdAt') + + if (search) { + const filters = this.restService.parseQueryStringFilter(search, { + channel: { + prefix: 'channel:' + } + }) + + if (filters.channel) { + url = `${UserSubscriptionService.BASE_VIDEO_CHANNELS_URL}/${filters.channel}/followers` + } + + params = this.restService.addObjectParams(params, { search: filters.search }) + } + + return this.authHttp + .get>(url, { params }) + .pipe( + catchError(err => this.restExtractor.handleError(err)) + ) + } + getUserSubscriptionVideos (parameters: { videoPagination: ComponentPaginationLight sort: VideoSortField skipCount?: boolean }): Observable> { const { videoPagination, sort, skipCount } = parameters - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + const pagination = this.restService.componentToRestPagination(videoPagination) let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) @@ -106,7 +141,7 @@ export class UserSubscriptionService { const { pagination, search } = parameters const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL - const restPagination = this.restService.componentPaginationToRestPagination(pagination) + const restPagination = this.restService.componentToRestPagination(pagination) let params = new HttpParams() params = this.restService.addRestGetParams(params, restPagination) diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts index 5550c96e4..fd1cae7f8 100644 --- a/client/src/app/shared/shared-video-comment/video-comment.service.ts +++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts @@ -81,7 +81,7 @@ export class VideoCommentService { }): Observable> { const { videoId, componentPagination, sort } = parameters - const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + const pagination = this.restService.componentToRestPagination(componentPagination) let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination, sort) 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 fc291329a..3faf81d11 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 @@ -62,7 +62,7 @@ export class VideoPlaylistService { listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable> { const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' - const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + const pagination = this.restService.componentToRestPagination(componentPagination) let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination) @@ -103,7 +103,7 @@ export class VideoPlaylistService { ): Observable> { const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' const pagination = componentPagination - ? this.restService.componentPaginationToRestPagination(componentPagination) + ? this.restService.componentToRestPagination(componentPagination) : undefined let params = new HttpParams() @@ -259,7 +259,7 @@ export class VideoPlaylistService { componentPagination: ComponentPaginationLight }): Observable> { const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + options.videoPlaylistId + '/videos' - const pagination = this.restService.componentPaginationToRestPagination(options.componentPagination) + const pagination = this.restService.componentToRestPagination(options.componentPagination) let params = new HttpParams() params = this.restService.addRestGetParams(params, pagination) diff --git a/client/src/sass/include/_account-channel-page.scss b/client/src/sass/include/_account-channel-page.scss new file mode 100644 index 000000000..b135bbb6d --- /dev/null +++ b/client/src/sass/include/_account-channel-page.scss @@ -0,0 +1,88 @@ +@use '_variables' as *; +@use '_mixins' as *; + +@mixin section-label-responsive { + color: pvar(--mainColor); + font-size: 12px; + margin-bottom: 15px; + font-weight: $font-bold; + letter-spacing: 2.5px; + + @media screen and (max-width: $mobile-view) { + font-size: 10px; + letter-spacing: 2.1px; + margin-bottom: 5px; + } +} + +@mixin show-more-description { + color: pvar(--mainColor); + cursor: pointer; + margin: 10px auto 45px; +} + +@mixin avatar-row-responsive ($img-margin, $grey-font-size) { + display: flex; + grid-column: 1; + margin-bottom: 30px; + + .main-avatar { + @include actor-avatar-size(120px); + } + + > div { + @include margin-left($img-margin); + + min-width: 1px; + } + + .actor-info { + display: flex; + + > div:first-child { + flex-grow: 1; + min-width: 1px; + } + } + + .actor-display-name { + @include peertube-word-wrap; + + display: flex; + flex-wrap: wrap; + } + + h1 { + font-size: 28px; + font-weight: $font-bold; + margin: 0; + } + + .actor-handle { + @include ellipsis; + } + + .actor-handle, + .actor-counters { + color: pvar(--greyForegroundColor); + font-size: $grey-font-size; + } + + .actor-counters > *:not(:last-child)::after { + content: '•'; + margin: 0 10px; + color: pvar(--mainColor); + } + + @media screen and (max-width: $mobile-view) { + margin-bottom: 15px; + + h1 { + font-size: 22px; + } + + .main-avatar { + @include actor-avatar-size(80px); + } + } +} diff --git a/client/src/sass/include/_actor.scss b/client/src/sass/include/_actor.scss index b135bbb6d..f9e44b8ad 100644 --- a/client/src/sass/include/_actor.scss +++ b/client/src/sass/include/_actor.scss @@ -1,88 +1,68 @@ @use '_variables' as *; @use '_mixins' as *; -@mixin section-label-responsive { - color: pvar(--mainColor); - font-size: 12px; - margin-bottom: 15px; - font-weight: $font-bold; - letter-spacing: 2.5px; - - @media screen and (max-width: $mobile-view) { - font-size: 10px; - letter-spacing: 2.1px; - margin-bottom: 5px; - } -} - -@mixin show-more-description { - color: pvar(--mainColor); - cursor: pointer; - margin: 10px auto 45px; -} - -@mixin avatar-row-responsive ($img-margin, $grey-font-size) { - display: flex; - grid-column: 1; - margin-bottom: 30px; +@mixin actor-row ($avatar-size: 80px, $avatar-margin-right: 10px, $min-height: 130px, $separator: true) { + @include row-blocks($min-height: $min-height, $separator: $separator); - .main-avatar { - @include actor-avatar-size(120px); - } - - > div { - @include margin-left($img-margin); + > my-actor-avatar { + @include actor-avatar-size($avatar-size); - min-width: 1px; + @include margin-right($avatar-margin-right); } .actor-info { - display: flex; - - > div:first-child { - flex-grow: 1; - min-width: 1px; - } + flex-grow: 1; } - .actor-display-name { - @include peertube-word-wrap; + .actor-names { + @include disable-default-a-behaviour; + width: fit-content; display: flex; - flex-wrap: wrap; + align-items: baseline; + color: pvar(--mainForegroundColor); } - h1 { - font-size: 28px; - font-weight: $font-bold; - margin: 0; + .actor-display-name { + font-weight: $font-semibold; + font-size: 18px; } - .actor-handle { - @include ellipsis; - } + .actor-name { + @include margin-left(5px); - .actor-handle, - .actor-counters { - color: pvar(--greyForegroundColor); - font-size: $grey-font-size; + font-size: 14px; + color: $grey-actor-name; } - .actor-counters > *:not(:last-child)::after { - content: '•'; - margin: 0 10px; - color: pvar(--mainColor); - } + .actor-owner { + @include disable-default-a-behaviour; - @media screen and (max-width: $mobile-view) { - margin-bottom: 15px; + font-size: 13px; + color: pvar(--mainForegroundColor); - h1 { - font-size: 22px; + span:hover { + opacity: 0.8; } - .main-avatar { - @include actor-avatar-size(80px); + my-actor-avatar { + @include margin-left(7px); + + display: inline-block; + vertical-align: top; + } + } + + @media screen and (max-width: $small-view) { + .actor-info { + padding-bottom: 10px; + text-align: center; + + .actor-names { + flex-direction: column; + align-items: center !important; + margin: auto; + } } } } diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 9e510771e..2f436d787 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -653,12 +653,15 @@ @include button-with-icon(20px, 5px, -1px); } -@mixin row-blocks ($column-responsive: true) { +@mixin row-blocks ($column-responsive: true, $min-height: 130px, $separator: true) { display: flex; - min-height: 130px; + min-height: $min-height; padding-bottom: 20px; margin-bottom: 20px; - border-bottom: 1px solid #C6C6C6; + + @if $separator { + border-bottom: 1px solid #C6C6C6; + } @media screen and (max-width: $small-view) { @if $column-responsive { diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index 75679b0f4..77edfa7c2 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -1,5 +1,6 @@ import express from 'express' import { pickCommonVideoQuery } from '@server/helpers/query' +import { ActorFollowModel } from '@server/models/actor/actor-follow' import { getServerActor } from '@server/models/application/application' import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' import { getFormattedObjects } from '../../helpers/utils' @@ -20,6 +21,7 @@ import { } from '../../middlewares' import { accountNameWithHostGetValidator, + accountsFollowersSortValidator, accountsSortValidator, ensureAuthUserOwnsAccountValidator, videoChannelsSortValidator, @@ -93,6 +95,17 @@ accountsRouter.get('/:accountName/ratings', asyncMiddleware(listAccountRatings) ) +accountsRouter.get('/:accountName/followers', + authenticate, + asyncMiddleware(accountNameWithHostGetValidator), + ensureAuthUserOwnsAccountValidator, + paginationValidator, + accountsFollowersSortValidator, + setDefaultSort, + setDefaultPagination, + asyncMiddleware(listAccountFollowers) +) + // --------------------------------------------------------------------------- export { @@ -127,7 +140,7 @@ async function listAccountChannels (req: express.Request, res: express.Response) search: req.query.search } - const resultList = await VideoChannelModel.listByAccount(options) + const resultList = await VideoChannelModel.listByAccountForAPI(options) return res.json(getFormattedObjects(resultList.data, resultList.total)) } @@ -195,3 +208,21 @@ async function listAccountRatings (req: express.Request, res: express.Response) }) return res.json(getFormattedObjects(resultList.rows, resultList.count)) } + +async function listAccountFollowers (req: express.Request, res: express.Response) { + const account = res.locals.account + + const channels = await VideoChannelModel.listAllByAccount(account.id) + const actorIds = [ account.actorId ].concat(channels.map(c => c.actorId)) + + const resultList = await ActorFollowModel.listFollowersForApi({ + actorIds, + start: req.query.start, + count: req.query.count, + sort: req.query.sort, + search: req.query.search, + state: 'accepted', + }) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts index 76ed75186..c613386b2 100644 --- a/server/controllers/api/server/follows.ts +++ b/server/controllers/api/server/follows.ts @@ -98,7 +98,7 @@ export { async function listFollowing (req: express.Request, res: express.Response) { const serverActor = await getServerActor() - const resultList = await ActorFollowModel.listFollowingForApi({ + const resultList = await ActorFollowModel.listInstanceFollowingForApi({ id: serverActor.id, start: req.query.start, count: req.query.count, @@ -114,7 +114,7 @@ async function listFollowing (req: express.Request, res: express.Response) { async function listFollowers (req: express.Request, res: express.Response) { const serverActor = await getServerActor() const resultList = await ActorFollowModel.listFollowersForApi({ - actorId: serverActor.id, + actorIds: [ serverActor.id ], start: req.query.start, count: req.query.count, sort: req.query.sort, diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts index e3c0cf089..b2b441673 100644 --- a/server/controllers/api/users/my-subscriptions.ts +++ b/server/controllers/api/users/my-subscriptions.ts @@ -95,7 +95,7 @@ async function areSubscriptionsExist (req: express.Request, res: express.Respons return { name, host, uri: u } }) - const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles) + const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, handles) const existObject: { [id: string ]: boolean } = {} for (const handle of handles) { diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index b79dc5933..f370c7004 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -1,6 +1,7 @@ import express from 'express' import { pickCommonVideoQuery } from '@server/helpers/query' import { Hooks } from '@server/lib/plugins/hooks' +import { ActorFollowModel } from '@server/models/actor/actor-follow' import { getServerActor } from '@server/models/application/application' import { MChannelBannerAccountDefault } from '@server/types/models' import { ActorImageType, VideoChannelCreate, VideoChannelUpdate } from '../../../shared' @@ -33,7 +34,13 @@ import { videoChannelsUpdateValidator, videoPlaylistsSortValidator } from '../../middlewares' -import { videoChannelsListValidator, videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators' +import { + ensureAuthUserOwnsChannelValidator, + videoChannelsFollowersSortValidator, + videoChannelsListValidator, + videoChannelsNameWithHostValidator, + videosSortValidator +} from '../../middlewares/validators' import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' import { AccountModel } from '../../models/account/account' @@ -65,8 +72,8 @@ videoChannelRouter.post('/', videoChannelRouter.post('/:nameWithHost/avatar/pick', authenticate, reqAvatarFile, - // Check the rights - asyncMiddleware(videoChannelsUpdateValidator), + asyncMiddleware(videoChannelsNameWithHostValidator), + ensureAuthUserOwnsChannelValidator, updateAvatarValidator, asyncMiddleware(updateVideoChannelAvatar) ) @@ -74,29 +81,31 @@ videoChannelRouter.post('/:nameWithHost/avatar/pick', videoChannelRouter.post('/:nameWithHost/banner/pick', authenticate, reqBannerFile, - // Check the rights - asyncMiddleware(videoChannelsUpdateValidator), + asyncMiddleware(videoChannelsNameWithHostValidator), + ensureAuthUserOwnsChannelValidator, updateBannerValidator, asyncMiddleware(updateVideoChannelBanner) ) videoChannelRouter.delete('/:nameWithHost/avatar', authenticate, - // Check the rights - asyncMiddleware(videoChannelsUpdateValidator), + asyncMiddleware(videoChannelsNameWithHostValidator), + ensureAuthUserOwnsChannelValidator, asyncMiddleware(deleteVideoChannelAvatar) ) videoChannelRouter.delete('/:nameWithHost/banner', authenticate, - // Check the rights - asyncMiddleware(videoChannelsUpdateValidator), + asyncMiddleware(videoChannelsNameWithHostValidator), + ensureAuthUserOwnsChannelValidator, asyncMiddleware(deleteVideoChannelBanner) ) videoChannelRouter.put('/:nameWithHost', authenticate, - asyncMiddleware(videoChannelsUpdateValidator), + asyncMiddleware(videoChannelsNameWithHostValidator), + ensureAuthUserOwnsChannelValidator, + videoChannelsUpdateValidator, asyncRetryTransactionMiddleware(updateVideoChannel) ) @@ -132,6 +141,17 @@ videoChannelRouter.get('/:nameWithHost/videos', asyncMiddleware(listVideoChannelVideos) ) +videoChannelRouter.get('/:nameWithHost/followers', + authenticate, + asyncMiddleware(videoChannelsNameWithHostValidator), + ensureAuthUserOwnsChannelValidator, + paginationValidator, + videoChannelsFollowersSortValidator, + setDefaultSort, + setDefaultPagination, + asyncMiddleware(listVideoChannelFollowers) +) + // --------------------------------------------------------------------------- export { @@ -332,3 +352,18 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon return res.json(getFormattedObjects(resultList.data, resultList.total)) } + +async function listVideoChannelFollowers (req: express.Request, res: express.Response) { + const channel = res.locals.videoChannel + + const resultList = await ActorFollowModel.listFollowersForApi({ + actorIds: [ channel.actorId ], + start: req.query.start, + count: req.query.count, + sort: req.query.sort, + search: req.query.search, + state: 'accepted', + }) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 029984559..dcbad9264 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -69,8 +69,11 @@ const SORTABLE_COLUMNS = { VIDEO_RATES: [ 'createdAt' ], BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], + INSTANCE_FOLLOWERS: [ 'createdAt', 'state', 'score' ], INSTANCE_FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ], + ACCOUNT_FOLLOWERS: [ 'createdAt' ], + CHANNEL_FOLLOWERS: [ 'createdAt' ], VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ], diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index ce8df8fee..3ba668460 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -53,6 +53,9 @@ const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS) const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) +const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) +const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) + // --------------------------------------------------------------------------- export { @@ -79,5 +82,7 @@ export { videoPlaylistsSortValidator, videoRedundanciesSortValidator, videoPlaylistsSearchSortValidator, + accountsFollowersSortValidator, + videoChannelsFollowersSortValidator, pluginsSortValidator } diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index c06b85862..c6eeeaf18 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts @@ -3,9 +3,7 @@ import { body, param, query } from 'express-validator' import { omit } from 'lodash' import { Hooks } from '@server/lib/plugins/hooks' import { MUserDefault } from '@server/types/models' -import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' -import { UserRole } from '../../../shared/models/users' -import { UserRegister } from '../../../shared/models/users/user-register.model' +import { HttpStatusCode, UserRegister, UserRole } from '@shared/models' import { toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' import { isThemeNameValid } from '../../helpers/custom-validators/plugins' import { @@ -462,7 +460,22 @@ const ensureAuthUserOwnsAccountValidator = [ if (res.locals.account.id !== user.Account.id) { return res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: 'Only owner can access ratings list.' + message: 'Only owner of this account can access this ressource.' + }) + } + + return next() + } +] + +const ensureAuthUserOwnsChannelValidator = [ + (req: express.Request, res: express.Response, next: express.NextFunction) => { + const user = res.locals.oauth.token.User + + if (res.locals.videoChannel.Account.userId !== user.id) { + return res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: 'Only owner of this video channel can access this ressource' }) } @@ -506,6 +519,7 @@ export { usersVerifyEmailValidator, userAutocompleteValidator, ensureAuthUserOwnsAccountValidator, + ensureAuthUserOwnsChannelValidator, ensureCanManageUser } diff --git a/server/middlewares/validators/videos/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts index fc717abf6..ec107fa51 100644 --- a/server/middlewares/validators/videos/video-channels.ts +++ b/server/middlewares/validators/videos/video-channels.ts @@ -65,22 +65,6 @@ const videoChannelsUpdateValidator = [ logger.debug('Checking videoChannelsUpdate parameters', { parameters: req.body }) if (areValidationErrors(req, res)) return - if (!await doesVideoChannelNameWithHostExist(req.params.nameWithHost, res)) return - - // We need to make additional checks - if (res.locals.videoChannel.Actor.isOwned() === false) { - return res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot update video channel of another server' - }) - } - - if (res.locals.videoChannel.Account.userId !== res.locals.oauth.token.User.id) { - return res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot update video channel of another user' - }) - } return next() } diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts index fc1cc7499..d6a2387a5 100644 --- a/server/models/actor/actor-follow.ts +++ b/server/models/actor/actor-follow.ts @@ -143,7 +143,7 @@ export class ActorFollowModel extends Model { + static listSubscriptionsOf (actorId: number, targets: { name: string, host?: string }[]): Promise { const whereTab = targets .map(t => { if (t.host) { @@ -348,7 +348,7 @@ export class ActorFollowModel extends ModelActor"."serverId" = "Account->Actor->Server"."id"` }) } - static listByAccount (options: { + static listByAccountForAPI (options: { accountId: number start: number count: number @@ -582,6 +582,26 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"` }) } + + static listAllByAccount (accountId: number) { + const query = { + limit: VIDEO_CHANNELS.MAX_PER_USER, + include: [ + { + attributes: [], + model: AccountModel, + where: { + id: accountId + }, + required: true + } + ] + } + + return VideoChannelModel.findAll(query) + } + + static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise { return VideoChannelModel.unscoped() .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ]) diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts index 58b360f92..517e2f423 100644 --- a/server/tests/api/check-params/users.ts +++ b/server/tests/api/check-params/users.ts @@ -840,6 +840,34 @@ describe('Test users API validators', function () { }) }) + describe('When getting my global followers', function () { + const path = '/api/v1/accounts/user1/followers' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, userToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, userToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, userToken) + }) + + it('Should fail with a unauthenticated user', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a another user', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ url: server.url, path, token: userToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + describe('When blocking/unblocking/removing user', function () { it('Should fail with an incorrect id', async function () { diff --git a/server/tests/api/check-params/video-channels.ts b/server/tests/api/check-params/video-channels.ts index 2e63916d4..e86c315fa 100644 --- a/server/tests/api/check-params/video-channels.ts +++ b/server/tests/api/check-params/video-channels.ts @@ -321,6 +321,34 @@ describe('Test video channels API validator', function () { }) }) + describe('When getting channel followers', function () { + const path = '/api/v1/video-channels/super_channel/followers' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a unauthenticated user', async function () { + await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should fail with a another user', async function () { + await makeGetRequest({ url: server.url, path, token: accessTokenUser, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + describe('When deleting a video channel', function () { it('Should fail with a non authenticated user', async function () { await command.delete({ token: 'coucou', channelName: 'super_channel', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts index 441f70d07..b49367be6 100644 --- a/server/tests/api/users/user-subscriptions.ts +++ b/server/tests/api/users/user-subscriptions.ts @@ -368,6 +368,162 @@ describe('Test users subscriptions', function () { } }) + it('Should follow user channels of server 3 by root of server 3', async function () { + this.timeout(60000) + + await servers[2].channels.create({ token: users[2].accessToken, attributes: { name: 'user3_channel2' } }) + + await servers[2].subscriptions.add({ token: servers[2].accessToken, targetUri: 'user3_channel@localhost:' + servers[2].port }) + await servers[2].subscriptions.add({ token: servers[2].accessToken, targetUri: 'user3_channel2@localhost:' + servers[2].port }) + + await waitJobs(servers) + }) + + it('Should list user 3 followers', async function () { + { + const { total, data } = await servers[2].accounts.listFollowers({ + token: users[2].accessToken, + accountName: 'user3', + start: 0, + count: 5, + sort: 'createdAt' + }) + + expect(total).to.equal(3) + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('user1') + + expect(data[1].following.host).to.equal(servers[2].host) + expect(data[1].following.name).to.equal('user3_channel') + expect(data[1].follower.host).to.equal(servers[2].host) + expect(data[1].follower.name).to.equal('root') + + expect(data[2].following.host).to.equal(servers[2].host) + expect(data[2].following.name).to.equal('user3_channel2') + expect(data[2].follower.host).to.equal(servers[2].host) + expect(data[2].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].accounts.listFollowers({ + token: users[2].accessToken, + accountName: 'user3', + start: 0, + count: 1, + sort: '-createdAt' + }) + + expect(total).to.equal(3) + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel2') + expect(data[0].follower.host).to.equal(servers[2].host) + expect(data[0].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].accounts.listFollowers({ + token: users[2].accessToken, + accountName: 'user3', + start: 1, + count: 1, + sort: '-createdAt' + }) + + expect(total).to.equal(3) + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[2].host) + expect(data[0].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].accounts.listFollowers({ + token: users[2].accessToken, + accountName: 'user3', + search: 'user1', + sort: '-createdAt' + }) + + expect(total).to.equal(1) + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('user1') + } + }) + + it('Should list user3_channel followers', async function () { + { + const { total, data } = await servers[2].channels.listFollowers({ + token: users[2].accessToken, + channelName: 'user3_channel', + start: 0, + count: 5, + sort: 'createdAt' + }) + + expect(total).to.equal(2) + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('user1') + + expect(data[1].following.host).to.equal(servers[2].host) + expect(data[1].following.name).to.equal('user3_channel') + expect(data[1].follower.host).to.equal(servers[2].host) + expect(data[1].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].channels.listFollowers({ + token: users[2].accessToken, + channelName: 'user3_channel', + start: 0, + count: 1, + sort: '-createdAt' + }) + + expect(total).to.equal(2) + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[2].host) + expect(data[0].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].channels.listFollowers({ + token: users[2].accessToken, + channelName: 'user3_channel', + start: 1, + count: 1, + sort: '-createdAt' + }) + + expect(total).to.equal(2) + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('root') + } + + { + const { total, data } = await servers[2].channels.listFollowers({ + token: users[2].accessToken, + channelName: 'user3_channel', + search: 'user1', + sort: '-createdAt' + }) + + expect(total).to.equal(1) + expect(data[0].following.host).to.equal(servers[2].host) + expect(data[0].following.name).to.equal('user3_channel') + expect(data[0].follower.host).to.equal(servers[0].host) + expect(data[0].follower.name).to.equal('user1') + } + }) + after(async function () { await cleanupTests(servers) }) diff --git a/shared/extra-utils/users/accounts-command.ts b/shared/extra-utils/users/accounts-command.ts index 2f586104e..98d9d5927 100644 --- a/shared/extra-utils/users/accounts-command.ts +++ b/shared/extra-utils/users/accounts-command.ts @@ -1,5 +1,5 @@ import { HttpStatusCode, ResultList } from '@shared/models' -import { Account } from '../../models/actors' +import { Account, ActorFollow } from '../../models/actors' import { AccountVideoRate, VideoRateType } from '../../models/videos' import { AbstractCommand, OverrideCommandOptions } from '../shared' @@ -53,4 +53,26 @@ export class AccountsCommand extends AbstractCommand { defaultExpectedStatus: HttpStatusCode.OK_200 }) } + + listFollowers (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + search?: string + }) { + const { accountName, start, count, sort, search } = options + const path = '/api/v1/accounts/' + accountName + '/followers' + + const query = { start, count, sort, search } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } } diff --git a/shared/extra-utils/videos/channels-command.ts b/shared/extra-utils/videos/channels-command.ts index 255e1d62d..e406e570b 100644 --- a/shared/extra-utils/videos/channels-command.ts +++ b/shared/extra-utils/videos/channels-command.ts @@ -1,5 +1,5 @@ import { pick } from '@shared/core-utils' -import { HttpStatusCode, ResultList, VideoChannel, VideoChannelCreateResult } from '@shared/models' +import { ActorFollow, HttpStatusCode, ResultList, VideoChannel, VideoChannelCreateResult } from '@shared/models' import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model' import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model' import { unwrapBody } from '../requests' @@ -47,7 +47,7 @@ export class ChannelsCommand extends AbstractCommand { } async create (options: OverrideCommandOptions & { - attributes: VideoChannelCreate + attributes: Partial }) { const path = '/api/v1/video-channels/' @@ -153,4 +153,26 @@ export class ChannelsCommand extends AbstractCommand { defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 }) } + + listFollowers (options: OverrideCommandOptions & { + channelName: string + start?: number + count?: number + sort?: string + search?: string + }) { + const { channelName, start, count, sort, search } = options + const path = '/api/v1/video-channels/' + channelName + '/followers' + + const query = { start, count, sort, search } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } } -- 2.41.0