diff --git a/client/src/app/+admin/moderation/moderation.component.scss b/client/src/app/+admin/moderation/moderation.component.scss index 5bb2c50a7..02ccfc8ca 100644 --- a/client/src/app/+admin/moderation/moderation.component.scss +++ b/client/src/app/+admin/moderation/moderation.component.scss @@ -17,8 +17,8 @@ } .moderation-expanded { - word-wrap: break-word; - overflow: visible !important; - text-overflow: unset !important; - white-space: unset !important; + word-wrap: break-word; + overflow: visible !important; + text-overflow: unset !important; + white-space: unset !important; } diff --git a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html new file mode 100644 index 000000000..fd7d7d23b --- /dev/null +++ b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html @@ -0,0 +1,34 @@ + + + + + + + diff --git a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.scss b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.scss new file mode 100644 index 000000000..ad6117413 --- /dev/null +++ b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.scss @@ -0,0 +1,10 @@ +@import '_variables'; +@import '_mixins'; + +select { + display: block; +} + +.form-group { + margin: 20px 0; +} \ No newline at end of file diff --git a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts new file mode 100644 index 000000000..a68b452ec --- /dev/null +++ b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts @@ -0,0 +1,79 @@ +import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' +import { NotificationsService } from 'angular2-notifications' +import { FormReactive } from '@app/shared' +import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' +import { VideoOwnershipService } from '@app/shared/video-ownership' +import { VideoChangeOwnership } from '../../../../../../shared/models/videos' +import { VideoAcceptOwnershipValidatorsService } from '@app/shared/forms/form-validators' +import { VideoChannel } from '@app/shared/video-channel/video-channel.model' +import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { AuthService } from '@app/core' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' + +@Component({ + selector: 'my-account-accept-ownership', + templateUrl: './my-account-accept-ownership.component.html', + styleUrls: [ './my-account-accept-ownership.component.scss' ] +}) +export class MyAccountAcceptOwnershipComponent extends FormReactive implements OnInit { + @Output() accepted = new EventEmitter() + + @ViewChild('modal') modal: ElementRef + + videoChangeOwnership: VideoChangeOwnership | undefined = undefined + + videoChannels: VideoChannel[] + + error: string = null + + constructor ( + protected formValidatorService: FormValidatorService, + private videoChangeOwnershipValidatorsService: VideoAcceptOwnershipValidatorsService, + private videoOwnershipService: VideoOwnershipService, + private notificationsService: NotificationsService, + private authService: AuthService, + private videoChannelService: VideoChannelService, + private modalService: NgbModal, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + this.videoChannels = [] + + this.videoChannelService.listAccountVideoChannels(this.authService.getUser().account) + .subscribe(videoChannels => this.videoChannels = videoChannels.data) + + this.buildForm({ + channel: this.videoChangeOwnershipValidatorsService.CHANNEL + }) + } + + show (videoChangeOwnership: VideoChangeOwnership) { + this.videoChangeOwnership = videoChangeOwnership + this.modalService + .open(this.modal) + .result + .then(() => this.acceptOwnership()) + .catch(() => this.videoChangeOwnership = undefined) + } + + acceptOwnership () { + const channel = this.form.value['channel'] + + const videoChangeOwnership = this.videoChangeOwnership + this.videoOwnershipService + .acceptOwnership(videoChangeOwnership.id, { channelId: channel }) + .subscribe( + () => { + this.notificationsService.success(this.i18n('Success'), this.i18n('Ownership accepted')) + if (this.accepted) this.accepted.emit() + this.videoChangeOwnership = undefined + }, + + err => this.notificationsService.error(this.i18n('Error'), err.message) + ) + } +} diff --git a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html new file mode 100644 index 000000000..379fd8bb1 --- /dev/null +++ b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html @@ -0,0 +1,54 @@ + + + + Initiator + Video + + Created + + + Status + Action + + + + + + + + {{ createByString(videoChangeOwnership.initiatorAccount) }} + + + + + {{ videoChangeOwnership.video.name }} + + + {{ videoChangeOwnership.createdAt }} + {{ videoChangeOwnership.status }} + + + + Refuse + + + + + + + \ No newline at end of file diff --git a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts new file mode 100644 index 000000000..13517b9f4 --- /dev/null +++ b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts @@ -0,0 +1,68 @@ +import { Component, OnInit, ViewChild } from '@angular/core' +import { NotificationsService } from 'angular2-notifications' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { RestPagination, RestTable } from '@app/shared' +import { SortMeta } from 'primeng/components/common/sortmeta' +import { VideoChangeOwnership } from '../../../../../shared' +import { VideoOwnershipService } from '@app/shared/video-ownership' +import { Account } from '@app/shared/account/account.model' +import { MyAccountAcceptOwnershipComponent } +from '@app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component' + +@Component({ + selector: 'my-account-ownership', + templateUrl: './my-account-ownership.component.html' +}) +export class MyAccountOwnershipComponent extends RestTable implements OnInit { + videoChangeOwnerships: VideoChangeOwnership[] = [] + totalRecords = 0 + rowsPerPage = 10 + sort: SortMeta = { field: 'createdAt', order: -1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + @ViewChild('myAccountAcceptOwnershipComponent') myAccountAcceptOwnershipComponent: MyAccountAcceptOwnershipComponent + + constructor ( + private notificationsService: NotificationsService, + private videoOwnershipService: VideoOwnershipService, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + this.loadSort() + } + + protected loadData () { + return this.videoOwnershipService.getOwnershipChanges(this.pagination, this.sort) + .subscribe( + resultList => { + this.videoChangeOwnerships = resultList.data + this.totalRecords = resultList.total + }, + + err => this.notificationsService.error(this.i18n('Error'), err.message) + ) + } + + createByString (account: Account) { + return Account.CREATE_BY_STRING(account.name, account.host) + } + + openAcceptModal (videoChangeOwnership: VideoChangeOwnership) { + this.myAccountAcceptOwnershipComponent.show(videoChangeOwnership) + } + + accepted () { + this.loadData() + } + + refuse (videoChangeOwnership: VideoChangeOwnership) { + this.videoOwnershipService.refuseOwnership(videoChangeOwnership.id) + .subscribe( + () => this.loadData(), + err => this.notificationsService.error(this.i18n('Error'), err.message) + ) + } +} diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts index c1c979151..4b2168e35 100644 --- a/client/src/app/+my-account/my-account-routing.module.ts +++ b/client/src/app/+my-account/my-account-routing.module.ts @@ -10,6 +10,7 @@ import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-accoun import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component' import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component' import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component' +import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownership/my-account-ownership.component' const myAccountRoutes: Routes = [ { @@ -84,6 +85,15 @@ const myAccountRoutes: Routes = [ title: 'Account subscriptions' } } + }, + { + path: 'ownership', + component: MyAccountOwnershipComponent, + data: { + meta: { + title: 'Ownership changes' + } + } } ] } diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html index 8a6cb5c32..276d01408 100644 --- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html +++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html @@ -42,7 +42,15 @@ + + + + \ No newline at end of file diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss b/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss index cd805be73..8d0dec07d 100644 --- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss +++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss @@ -35,12 +35,6 @@ } } -/deep/ .action-button { - &.action-button-delete { - margin-right: 10px; - } -} - .video { @include row-blocks; @@ -96,6 +90,10 @@ .video-buttons { min-width: 190px; + + *:not(:last-child) { + margin-right: 10px; + } } } diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts b/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts index 01e1ef1da..7560f0128 100644 --- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts +++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts @@ -1,6 +1,6 @@ import { from as observableFrom, Observable } from 'rxjs' import { concatAll, tap } from 'rxjs/operators' -import { Component, OnDestroy, OnInit, Inject, LOCALE_ID } from '@angular/core' +import { Component, OnDestroy, OnInit, Inject, LOCALE_ID, ViewChild } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { Location } from '@angular/common' import { immutableAssign } from '@app/shared/misc/utils' @@ -14,6 +14,7 @@ import { VideoService } from '../../shared/video/video.service' import { I18n } from '@ngx-translate/i18n-polyfill' import { VideoPrivacy, VideoState } from '../../../../../shared/models/videos' import { ScreenService } from '@app/shared/misc/screen.service' +import { VideoChangeOwnershipComponent } from './video-change-ownership/video-change-ownership.component' @Component({ selector: 'my-account-videos', @@ -33,6 +34,8 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni protected baseVideoWidth = -1 protected baseVideoHeight = 155 + @ViewChild('videoChangeOwnershipModal') videoChangeOwnershipModal: VideoChangeOwnershipComponent + constructor ( protected router: Router, protected route: ActivatedRoute, @@ -133,6 +136,11 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni ) } + changeOwnership (event: Event, video: Video) { + event.preventDefault() + this.videoChangeOwnershipModal.show(video) + } + getStateLabel (video: Video) { let suffix: string diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html new file mode 100644 index 000000000..69b198faa --- /dev/null +++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html @@ -0,0 +1,31 @@ + + + + + + + diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.scss b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.scss new file mode 100644 index 000000000..a79fec179 --- /dev/null +++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.scss @@ -0,0 +1,10 @@ +@import '_variables'; +@import '_mixins'; + +p-autocomplete { + display: block; +} + +.form-group { + margin: 20px 0; +} \ No newline at end of file diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts new file mode 100644 index 000000000..0aa4c32ee --- /dev/null +++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts @@ -0,0 +1,75 @@ +import { Component, ElementRef, OnInit, ViewChild } from '@angular/core' +import { NotificationsService } from 'angular2-notifications' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { FormReactive, UserService } from '../../../shared/index' +import { Video } from '@app/shared/video/video.model' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { FormValidatorService, VideoChangeOwnershipValidatorsService } from '@app/shared' +import { VideoOwnershipService } from '@app/shared/video-ownership' + +@Component({ + selector: 'my-video-change-ownership', + templateUrl: './video-change-ownership.component.html', + styleUrls: [ './video-change-ownership.component.scss' ] +}) +export class VideoChangeOwnershipComponent extends FormReactive implements OnInit { + @ViewChild('modal') modal: ElementRef + + usernamePropositions: string[] + + error: string = null + + private video: Video | undefined = undefined + + constructor ( + protected formValidatorService: FormValidatorService, + private videoChangeOwnershipValidatorsService: VideoChangeOwnershipValidatorsService, + private videoOwnershipService: VideoOwnershipService, + private notificationsService: NotificationsService, + private userService: UserService, + private modalService: NgbModal, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + this.buildForm({ + username: this.videoChangeOwnershipValidatorsService.USERNAME + }) + this.usernamePropositions = [] + } + + show (video: Video) { + this.video = video + this.modalService + .open(this.modal) + .result + .then(() => this.changeOwnership()) + .catch((_) => _) // Called when closing (cancel) the modal without validating, do nothing + } + + search (event) { + const query = event.query + this.userService.autocomplete(query) + .subscribe( + (usernames) => { + this.usernamePropositions = usernames + }, + + err => this.notificationsService.error('Error', err.message) + ) + } + + changeOwnership () { + const username = this.form.value['username'] + + this.videoOwnershipService + .changeOwnership(this.video.id, username) + .subscribe( + () => this.notificationsService.success(this.i18n('Success'), this.i18n('Ownership changed.')), + + err => this.notificationsService.error(this.i18n('Error'), err.message) + ) + } +} diff --git a/client/src/app/+my-account/my-account.component.html b/client/src/app/+my-account/my-account.component.html index 74742649c..b79e61bef 100644 --- a/client/src/app/+my-account/my-account.component.html +++ b/client/src/app/+my-account/my-account.component.html @@ -9,6 +9,8 @@ My subscriptions My imports + + Ownership changes
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index c93f38d4b..ad21162a8 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts @@ -1,5 +1,6 @@ import { TableModule } from 'primeng/table' import { NgModule } from '@angular/core' +import { AutoCompleteModule } from 'primeng/autocomplete' import { SharedModule } from '../shared' import { MyAccountRoutingModule } from './my-account-routing.module' import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' @@ -7,6 +8,9 @@ import { MyAccountVideoSettingsComponent } from './my-account-settings/my-accoun import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' import { MyAccountComponent } from './my-account.component' import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component' +import { VideoChangeOwnershipComponent } from './my-account-videos/video-change-ownership/video-change-ownership.component' +import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component' +import { MyAccountAcceptOwnershipComponent } from './my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component' import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/my-account-profile/my-account-profile.component' import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component' import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component' @@ -18,7 +22,9 @@ import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-sub @NgModule({ imports: [ + TableModule, MyAccountRoutingModule, + AutoCompleteModule, SharedModule, TableModule ], @@ -30,6 +36,9 @@ import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-sub MyAccountVideoSettingsComponent, MyAccountProfileComponent, MyAccountVideosComponent, + VideoChangeOwnershipComponent, + MyAccountOwnershipComponent, + MyAccountAcceptOwnershipComponent, MyAccountVideoChannelsComponent, MyAccountVideoChannelCreateComponent, MyAccountVideoChannelUpdateComponent, diff --git a/client/src/app/shared/buttons/button.component.html b/client/src/app/shared/buttons/button.component.html new file mode 100644 index 000000000..87a8daccf --- /dev/null +++ b/client/src/app/shared/buttons/button.component.html @@ -0,0 +1,4 @@ + + + {{ label }} + diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss index 343aea207..168102f09 100644 --- a/client/src/app/shared/buttons/button.component.scss +++ b/client/src/app/shared/buttons/button.component.scss @@ -26,6 +26,18 @@ &.icon-delete-grey { background-image: url('../../../assets/images/global/delete-grey.svg'); } + + &.icon-im-with-her { + background-image: url('../../../assets/images/global/im-with-her.svg'); + } + + &.icon-tick { + background-image: url('../../../assets/images/global/tick.svg'); + } + + &.icon-cross { + background-image: url('../../../assets/images/global/cross.svg'); + } } } diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts new file mode 100644 index 000000000..967cb1409 --- /dev/null +++ b/client/src/app/shared/buttons/button.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core' + +@Component({ + selector: 'my-button', + styleUrls: ['./button.component.scss'], + templateUrl: './button.component.html' +}) + +export class ButtonComponent { + @Input() label = '' + @Input() className = undefined + @Input() icon = undefined + @Input() title = undefined + + getTitle () { + return this.title || this.label + } +} diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts index 9bc7615ca..74e385b3d 100644 --- a/client/src/app/shared/forms/form-validators/index.ts +++ b/client/src/app/shared/forms/form-validators/index.ts @@ -10,3 +10,5 @@ export * from './video-channel-validators.service' export * from './video-comment-validators.service' export * from './video-validators.service' export * from './video-captions-validators.service' +export * from './video-change-ownership-validators.service' +export * from './video-accept-ownership-validators.service' diff --git a/client/src/app/shared/forms/form-validators/video-accept-ownership-validators.service.ts b/client/src/app/shared/forms/form-validators/video-accept-ownership-validators.service.ts new file mode 100644 index 000000000..48c7054a4 --- /dev/null +++ b/client/src/app/shared/forms/form-validators/video-accept-ownership-validators.service.ts @@ -0,0 +1,18 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from '@app/shared' + +@Injectable() +export class VideoAcceptOwnershipValidatorsService { + readonly CHANNEL: BuildFormValidator + + constructor (private i18n: I18n) { + this.CHANNEL = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('The channel is required.') + } + } + } +} diff --git a/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts b/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts new file mode 100644 index 000000000..087b80b44 --- /dev/null +++ b/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts @@ -0,0 +1,18 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from '@app/shared' + +@Injectable() +export class VideoChangeOwnershipValidatorsService { + readonly USERNAME: BuildFormValidator + + constructor (private i18n: I18n) { + this.USERNAME = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('The username is required.') + } + } + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index b96a9aa41..1e71feb86 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -12,6 +12,7 @@ import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' import { AUTH_INTERCEPTOR_PROVIDER } from './auth' +import { ButtonComponent } from './buttons/button.component' import { DeleteButtonComponent } from './buttons/delete-button.component' import { EditButtonComponent } from './buttons/edit-button.component' import { FromNowPipe } from './misc/from-now.pipe' @@ -22,6 +23,7 @@ import { RestExtractor, RestService } from './rest' import { UserService } from './users' import { VideoAbuseService } from './video-abuse' import { VideoBlacklistService } from './video-blacklist' +import { VideoOwnershipService } from './video-ownership' import { VideoMiniatureComponent } from './video/video-miniature.component' import { VideoFeedComponent } from './video/video-feed.component' import { VideoThumbnailComponent } from './video/video-thumbnail.component' @@ -40,7 +42,8 @@ import { VideoBlacklistValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, - VideoValidatorsService + VideoValidatorsService, + VideoChangeOwnershipValidatorsService, VideoAcceptOwnershipValidatorsService } from '@app/shared/forms' import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' import { ScreenService } from '@app/shared/misc/screen.service' @@ -77,6 +80,7 @@ import { OverviewService } from '@app/shared/overview' VideoThumbnailComponent, VideoMiniatureComponent, VideoFeedComponent, + ButtonComponent, DeleteButtonComponent, EditButtonComponent, ActionDropdownComponent, @@ -113,6 +117,7 @@ import { OverviewService } from '@app/shared/overview' VideoThumbnailComponent, VideoMiniatureComponent, VideoFeedComponent, + ButtonComponent, DeleteButtonComponent, EditButtonComponent, ActionDropdownComponent, @@ -135,6 +140,7 @@ import { OverviewService } from '@app/shared/overview' RestService, VideoAbuseService, VideoBlacklistService, + VideoOwnershipService, UserService, VideoService, AccountService, @@ -156,6 +162,8 @@ import { OverviewService } from '@app/shared/overview' VideoCaptionsValidatorsService, VideoBlacklistValidatorsService, OverviewService, + VideoChangeOwnershipValidatorsService, + VideoAcceptOwnershipValidatorsService, I18nPrimengCalendarService, ScreenService, diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts index 249c589b7..fad5b0980 100644 --- a/client/src/app/shared/users/user.service.ts +++ b/client/src/app/shared/users/user.service.ts @@ -1,5 +1,6 @@ +import { Observable } from 'rxjs' import { catchError, map } from 'rxjs/operators' -import { HttpClient } from '@angular/common/http' +import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { UserCreate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' import { environment } from '../../../environments/environment' @@ -117,4 +118,13 @@ export class UserService { catchError(err => this.restExtractor.handleError(err)) ) } + + autocomplete (search: string): Observable { + const url = UserService.BASE_USERS_URL + 'autocomplete' + const params = new HttpParams().append('search', search) + + return this.authHttp + .get(url, { params }) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } } diff --git a/client/src/app/shared/video-ownership/index.ts b/client/src/app/shared/video-ownership/index.ts new file mode 100644 index 000000000..fe8902ee2 --- /dev/null +++ b/client/src/app/shared/video-ownership/index.ts @@ -0,0 +1 @@ +export * from './video-ownership.service' diff --git a/client/src/app/shared/video-ownership/video-ownership.service.ts b/client/src/app/shared/video-ownership/video-ownership.service.ts new file mode 100644 index 000000000..aa9e4839a --- /dev/null +++ b/client/src/app/shared/video-ownership/video-ownership.service.ts @@ -0,0 +1,67 @@ +import { catchError, map } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { environment } from '../../../environments/environment' +import { RestExtractor, RestService } from '../rest' +import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos' +import { Observable } from 'rxjs/index' +import { SortMeta } from 'primeng/components/common/sortmeta' +import { ResultList, VideoChangeOwnership } from '../../../../../shared' +import { RestPagination } from '@app/shared/rest' +import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model' + +@Injectable() +export class VideoOwnershipService { + private static BASE_VIDEO_CHANGE_OWNERSHIP_URL = environment.apiUrl + '/api/v1/videos/' + + constructor ( + private authHttp: HttpClient, + private restService: RestService, + private restExtractor: RestExtractor + ) { + } + + changeOwnership (id: number, username: string) { + const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + id + '/give-ownership' + const body: VideoChangeOwnershipCreate = { + username + } + + return this.authHttp.post(url, body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + getOwnershipChanges (pagination: RestPagination, sort: SortMeta): Observable> { + const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership' + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + return this.authHttp.get>(url, { params }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + acceptOwnership (id: number, input: VideoChangeOwnershipAccept) { + const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/accept' + return this.authHttp.post(url, input) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(this.restExtractor.handleError) + ) + } + + refuseOwnership (id: number) { + const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/refuse' + return this.authHttp.post(url, {}) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(this.restExtractor.handleError) + ) + } +} diff --git a/client/src/assets/images/global/im-with-her.svg b/client/src/assets/images/global/im-with-her.svg new file mode 100644 index 000000000..31d4754fd --- /dev/null +++ b/client/src/assets/images/global/im-with-her.svg @@ -0,0 +1,15 @@ + + + + im-with-her + Created with Sketch. + + + + + + + + + + \ No newline at end of file diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 99225e4e5..547f03caa 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -23,7 +23,7 @@ * @param $line-height line-height property * @param $lines-to-show amount of lines to show */ - @mixin ellipsis-multiline($font-size: 1rem, $line-height: 1, $lines-to-show: 2) { +@mixin ellipsis-multiline($font-size: 1rem, $line-height: 1, $lines-to-show: 2) { display: block; /* Fallback for non-webkit */ display: -webkit-box; diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 01ee73a53..faba7e208 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -18,6 +18,7 @@ import { setDefaultPagination, setDefaultSort, token, + userAutocompleteValidator, usersAddValidator, usersGetValidator, usersRegisterValidator, @@ -51,6 +52,11 @@ const askSendEmailLimiter = new RateLimit({ const usersRouter = express.Router() usersRouter.use('/', meRouter) +usersRouter.get('/autocomplete', + userAutocompleteValidator, + asyncMiddleware(autocompleteUsers) +) + usersRouter.get('/', authenticate, ensureUserHasRight(UserRight.MANAGE_USERS), @@ -222,6 +228,12 @@ function getUser (req: express.Request, res: express.Response, next: express.Nex return res.json((res.locals.user as UserModel).toFormattedJSON()) } +async function autocompleteUsers (req: express.Request, res: express.Response, next: express.NextFunction) { + const resultList = await UserModel.autocomplete(req.query.search as string) + + return res.json(resultList) +} + async function listUsers (req: express.Request, res: express.Response, next: express.NextFunction) { const resultList = await UserModel.listForApi(req.query.start, req.query.count, req.query.sort) diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index be803490b..0c9e6c2d1 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -49,6 +49,7 @@ import { abuseVideoRouter } from './abuse' import { blacklistRouter } from './blacklist' import { videoCommentRouter } from './comment' import { rateVideoRouter } from './rate' +import { ownershipVideoRouter } from './ownership' import { VideoFilter } from '../../../../shared/models/videos/video-query.type' import { buildNSFWFilter, createReqFiles } from '../../../helpers/express-utils' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' @@ -84,6 +85,7 @@ videosRouter.use('/', rateVideoRouter) videosRouter.use('/', videoCommentRouter) videosRouter.use('/', videoCaptionsRouter) videosRouter.use('/', videoImportsRouter) +videosRouter.use('/', ownershipVideoRouter) videosRouter.get('/categories', listVideoCategories) videosRouter.get('/licences', listVideoLicences) diff --git a/server/controllers/api/videos/ownership.ts b/server/controllers/api/videos/ownership.ts new file mode 100644 index 000000000..fc42f5fff --- /dev/null +++ b/server/controllers/api/videos/ownership.ts @@ -0,0 +1,117 @@ +import * as express from 'express' +import { logger } from '../../../helpers/logger' +import { sequelizeTypescript } from '../../../initializers' +import { + asyncMiddleware, + asyncRetryTransactionMiddleware, + authenticate, + paginationValidator, + setDefaultPagination, + videosAcceptChangeOwnershipValidator, + videosChangeOwnershipValidator, + videosTerminateChangeOwnershipValidator +} from '../../../middlewares' +import { AccountModel } from '../../../models/account/account' +import { VideoModel } from '../../../models/video/video' +import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership' +import { VideoChangeOwnershipStatus } from '../../../../shared/models/videos' +import { VideoChannelModel } from '../../../models/video/video-channel' +import { getFormattedObjects } from '../../../helpers/utils' + +const ownershipVideoRouter = express.Router() + +ownershipVideoRouter.post('/:videoId/give-ownership', + authenticate, + asyncMiddleware(videosChangeOwnershipValidator), + asyncRetryTransactionMiddleware(giveVideoOwnership) +) + +ownershipVideoRouter.get('/ownership', + authenticate, + paginationValidator, + setDefaultPagination, + asyncRetryTransactionMiddleware(listVideoOwnership) +) + +ownershipVideoRouter.post('/ownership/:id/accept', + authenticate, + asyncMiddleware(videosTerminateChangeOwnershipValidator), + asyncMiddleware(videosAcceptChangeOwnershipValidator), + asyncRetryTransactionMiddleware(acceptOwnership) +) + +ownershipVideoRouter.post('/ownership/:id/refuse', + authenticate, + asyncMiddleware(videosTerminateChangeOwnershipValidator), + asyncRetryTransactionMiddleware(refuseOwnership) +) + +// --------------------------------------------------------------------------- + +export { + ownershipVideoRouter +} + +// --------------------------------------------------------------------------- + +async function giveVideoOwnership (req: express.Request, res: express.Response) { + const videoInstance = res.locals.video as VideoModel + const initiatorAccount = res.locals.oauth.token.User.Account as AccountModel + const nextOwner = res.locals.nextOwner as AccountModel + + await sequelizeTypescript.transaction(async t => { + await VideoChangeOwnershipModel.findOrCreate({ + where: { + initiatorAccountId: initiatorAccount.id, + nextOwnerAccountId: nextOwner.id, + videoId: videoInstance.id, + status: VideoChangeOwnershipStatus.WAITING + }, + defaults: { + initiatorAccountId: initiatorAccount.id, + nextOwnerAccountId: nextOwner.id, + videoId: videoInstance.id, + status: VideoChangeOwnershipStatus.WAITING + } + }) + logger.info('Ownership change for video %s created.', videoInstance.name) + return res.type('json').status(204).end() + }) +} + +async function listVideoOwnership (req: express.Request, res: express.Response) { + const currentAccount = res.locals.oauth.token.User.Account as AccountModel + const resultList = await VideoChangeOwnershipModel.listForApi( + currentAccount.id, + req.query.start || 0, + req.query.count || 10, + req.query.sort || 'createdAt' + ) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function acceptOwnership (req: express.Request, res: express.Response) { + return sequelizeTypescript.transaction(async t => { + const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel + const targetVideo = videoChangeOwnership.Video + const channel = res.locals.videoChannel as VideoChannelModel + + targetVideo.set('channelId', channel.id) + + await targetVideo.save() + videoChangeOwnership.set('status', VideoChangeOwnershipStatus.ACCEPTED) + await videoChangeOwnership.save() + + return res.sendStatus(204) + }) +} + +async function refuseOwnership (req: express.Request, res: express.Response) { + return sequelizeTypescript.transaction(async t => { + const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel + videoChangeOwnership.set('status', VideoChangeOwnershipStatus.REFUSED) + await videoChangeOwnership.save() + return res.sendStatus(204) + }) +} diff --git a/server/helpers/custom-validators/video-ownership.ts b/server/helpers/custom-validators/video-ownership.ts new file mode 100644 index 000000000..aaa0c736b --- /dev/null +++ b/server/helpers/custom-validators/video-ownership.ts @@ -0,0 +1,42 @@ +import { Response } from 'express' +import * as validator from 'validator' +import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership' +import { UserModel } from '../../models/account/user' + +export async function doesChangeVideoOwnershipExist (id: string, res: Response): Promise { + const videoChangeOwnership = await loadVideoChangeOwnership(id) + + if (!videoChangeOwnership) { + res.status(404) + .json({ error: 'Video change ownership not found' }) + .end() + + return false + } + + res.locals.videoChangeOwnership = videoChangeOwnership + return true +} + +async function loadVideoChangeOwnership (id: string): Promise { + if (validator.isInt(id)) { + return VideoChangeOwnershipModel.load(parseInt(id, 10)) + } + + return undefined +} + +export function checkUserCanTerminateOwnershipChange ( + user: UserModel, + videoChangeOwnership: VideoChangeOwnershipModel, + res: Response +): boolean { + if (videoChangeOwnership.NextOwner.userId === user.Account.userId) { + return true + } + + res.status(403) + .json({ error: 'Cannot terminate an ownership change of another user' }) + .end() + return false +} diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 78bc8101c..b68e1a882 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -26,6 +26,7 @@ import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update' import { VideoCaptionModel } from '../models/video/video-caption' import { VideoImportModel } from '../models/video/video-import' import { VideoViewModel } from '../models/video/video-views' +import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership' require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -75,6 +76,7 @@ async function initDatabaseModels (silent: boolean) { AccountVideoRateModel, UserModel, VideoAbuseModel, + VideoChangeOwnershipModel, VideoChannelModel, VideoShareModel, VideoFileModel, diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index a595c39ec..d13c50c84 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts @@ -290,6 +290,10 @@ const usersVerifyEmailValidator = [ } ] +const userAutocompleteValidator = [ + param('search').isString().not().isEmpty().withMessage('Should have a search parameter') +] + // --------------------------------------------------------------------------- export { @@ -307,7 +311,8 @@ export { usersAskResetPasswordValidator, usersResetPasswordValidator, usersAskSendVerifyEmailValidator, - usersVerifyEmailValidator + usersVerifyEmailValidator, + userAutocompleteValidator } // --------------------------------------------------------------------------- diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts index a2c866152..9befbc9ee 100644 --- a/server/middlewares/validators/videos.ts +++ b/server/middlewares/validators/videos.ts @@ -1,7 +1,7 @@ import * as express from 'express' import 'express-validator' import { body, param, ValidationChain } from 'express-validator/check' -import { UserRight, VideoPrivacy } from '../../../shared' +import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../shared' import { isBooleanValid, isDateValid, @@ -37,6 +37,10 @@ import { areValidationErrors } from './utils' import { cleanUpReqFiles } from '../../helpers/express-utils' import { VideoModel } from '../../models/video/video' import { UserModel } from '../../models/account/user' +import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../helpers/custom-validators/video-ownership' +import { VideoChangeOwnershipAccept } from '../../../shared/models/videos/video-change-ownership-accept.model' +import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership' +import { AccountModel } from '../../models/account/account' const videosAddValidator = getCommonVideoAttributes().concat([ body('videofile') @@ -217,6 +221,78 @@ const videosShareValidator = [ } ] +const videosChangeOwnershipValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking changeOwnership parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + + // Check if the user who did the request is able to change the ownership of the video + if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return + + const nextOwner = await AccountModel.loadLocalByName(req.body.username) + if (!nextOwner) { + res.status(400) + .type('json') + .end() + return + } + res.locals.nextOwner = nextOwner + + return next() + } +] + +const videosTerminateChangeOwnershipValidator = [ + param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking changeOwnership parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return + + // Check if the user who did the request is able to change the ownership of the video + if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return + + return next() + }, + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel + + if (videoChangeOwnership.status === VideoChangeOwnershipStatus.WAITING) { + return next() + } else { + res.status(403) + .json({ error: 'Ownership already accepted or refused' }) + .end() + return + } + } +] + +const videosAcceptChangeOwnershipValidator = [ + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + const body = req.body as VideoChangeOwnershipAccept + if (!await isVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return + + const user = res.locals.oauth.token.User + const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel + const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getOriginalFile()) + if (isAble === false) { + res.status(403) + .json({ error: 'The user video quota is exceeded with this video.' }) + .end() + return + } + + return next() + } +] + function getCommonVideoAttributes () { return [ body('thumbnailfile') @@ -295,6 +371,10 @@ export { videoRateValidator, + videosChangeOwnershipValidator, + videosTerminateChangeOwnershipValidator, + videosAcceptChangeOwnershipValidator, + getCommonVideoAttributes } diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 89265774b..4b13e47a0 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -39,6 +39,7 @@ import { AccountModel } from './account' import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type' import { values } from 'lodash' import { NSFW_POLICY_TYPES } from '../../initializers' +import { VideoFileModel } from '../video/video-file' enum ScopeNames { WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL' @@ -393,4 +394,15 @@ export class UserModel extends Model { return parseInt(total, 10) }) } + + static autocomplete (search: string) { + return UserModel.findAll({ + where: { + username: { + [Sequelize.Op.like]: `%${search}%` + } + } + }) + .then(u => u.map(u => u.username)) + } } diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts new file mode 100644 index 000000000..c9cff5054 --- /dev/null +++ b/server/models/video/video-change-ownership.ts @@ -0,0 +1,127 @@ +import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' +import { AccountModel } from '../account/account' +import { VideoModel } from './video' +import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' +import { getSort } from '../utils' +import { VideoFileModel } from './video-file' + +enum ScopeNames { + FULL = 'FULL' +} + +@Table({ + tableName: 'videoChangeOwnership', + indexes: [ + { + fields: ['videoId'] + }, + { + fields: ['initiatorAccountId'] + }, + { + fields: ['nextOwnerAccountId'] + } + ] +}) +@Scopes({ + [ScopeNames.FULL]: { + include: [ + { + model: () => AccountModel, + as: 'Initiator', + required: true + }, + { + model: () => AccountModel, + as: 'NextOwner', + required: true + }, + { + model: () => VideoModel, + required: true, + include: [{ model: () => VideoFileModel }] + } + ] + } +}) +export class VideoChangeOwnershipModel extends Model { + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @AllowNull(false) + @Column + status: VideoChangeOwnershipStatus + + @ForeignKey(() => AccountModel) + @Column + initiatorAccountId: number + + @BelongsTo(() => AccountModel, { + foreignKey: { + name: 'initiatorAccountId', + allowNull: false + }, + onDelete: 'cascade' + }) + Initiator: AccountModel + + @ForeignKey(() => AccountModel) + @Column + nextOwnerAccountId: number + + @BelongsTo(() => AccountModel, { + foreignKey: { + name: 'nextOwnerAccountId', + allowNull: false + }, + onDelete: 'cascade' + }) + NextOwner: AccountModel + + @ForeignKey(() => VideoModel) + @Column + videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'cascade' + }) + Video: VideoModel + + static listForApi (nextOwnerId: number, start: number, count: number, sort: string) { + return VideoChangeOwnershipModel.scope(ScopeNames.FULL).findAndCountAll({ + offset: start, + limit: count, + order: getSort(sort), + where: { + nextOwnerAccountId: nextOwnerId + } + }) + .then(({ rows, count }) => ({ total: count, data: rows })) + } + + static load (id: number) { + return VideoChangeOwnershipModel.scope(ScopeNames.FULL).findById(id) + } + + toFormattedJSON (): VideoChangeOwnership { + return { + id: this.id, + status: this.status, + initiatorAccount: this.Initiator.toFormattedJSON(), + nextOwnerAccount: this.NextOwner.toFormattedJSON(), + video: { + id: this.Video.id, + uuid: this.Video.uuid, + url: this.Video.url, + name: this.Video.name + }, + createdAt: this.createdAt + } + } +} diff --git a/server/tests/api/videos/video-change-ownership.ts b/server/tests/api/videos/video-change-ownership.ts new file mode 100644 index 000000000..275be40be --- /dev/null +++ b/server/tests/api/videos/video-change-ownership.ts @@ -0,0 +1,262 @@ +/* tslint:disable:no-unused-expression */ + +import * as chai from 'chai' +import 'mocha' +import { + acceptChangeOwnership, + changeVideoOwnership, + createUser, + flushTests, + getMyUserInformation, + getVideoChangeOwnershipList, + getVideosList, + killallServers, + refuseChangeOwnership, + runServer, + ServerInfo, + setAccessTokensToServers, + uploadVideo, + userLogin +} from '../../utils' +import { waitJobs } from '../../utils/server/jobs' +import { User } from '../../../../shared/models/users' + +const expect = chai.expect + +describe('Test video change ownership - nominal', function () { + let server: ServerInfo = undefined + const firstUser = { + username: 'first', + password: 'My great password' + } + const secondUser = { + username: 'second', + password: 'My other password' + } + let firstUserAccessToken = '' + let secondUserAccessToken = '' + let lastRequestChangeOwnershipId = undefined + + before(async function () { + this.timeout(50000) + + // Run one server + await flushTests() + server = await runServer(1) + await setAccessTokensToServers([server]) + + const videoQuota = 42000000 + await createUser(server.url, server.accessToken, firstUser.username, firstUser.password, videoQuota) + await createUser(server.url, server.accessToken, secondUser.username, secondUser.password, videoQuota) + + firstUserAccessToken = await userLogin(server, firstUser) + secondUserAccessToken = await userLogin(server, secondUser) + + // Upload some videos on the server + const video1Attributes = { + name: 'my super name', + description: 'my super description' + } + await uploadVideo(server.url, firstUserAccessToken, video1Attributes) + + await waitJobs(server) + + const res = await getVideosList(server.url) + const videos = res.body.data + + expect(videos.length).to.equal(1) + + server.video = videos.find(video => video.name === 'my super name') + }) + + it('Should not have video change ownership', async function () { + const resFirstUser = await getVideoChangeOwnershipList(server.url, firstUserAccessToken) + + expect(resFirstUser.body.total).to.equal(0) + expect(resFirstUser.body.data).to.be.an('array') + expect(resFirstUser.body.data.length).to.equal(0) + + const resSecondUser = await getVideoChangeOwnershipList(server.url, secondUserAccessToken) + + expect(resSecondUser.body.total).to.equal(0) + expect(resSecondUser.body.data).to.be.an('array') + expect(resSecondUser.body.data.length).to.equal(0) + }) + + it('Should send a request to change ownership of a video', async function () { + this.timeout(15000) + + await changeVideoOwnership(server.url, firstUserAccessToken, server.video.id, secondUser.username) + }) + + it('Should only return a request to change ownership for the second user', async function () { + const resFirstUser = await getVideoChangeOwnershipList(server.url, firstUserAccessToken) + + expect(resFirstUser.body.total).to.equal(0) + expect(resFirstUser.body.data).to.be.an('array') + expect(resFirstUser.body.data.length).to.equal(0) + + const resSecondUser = await getVideoChangeOwnershipList(server.url, secondUserAccessToken) + + expect(resSecondUser.body.total).to.equal(1) + expect(resSecondUser.body.data).to.be.an('array') + expect(resSecondUser.body.data.length).to.equal(1) + + lastRequestChangeOwnershipId = resSecondUser.body.data[0].id + }) + + it('Should accept the same change ownership request without crashing', async function () { + this.timeout(10000) + + await changeVideoOwnership(server.url, firstUserAccessToken, server.video.id, secondUser.username) + }) + + it('Should not create multiple change ownership requests while one is waiting', async function () { + this.timeout(10000) + + const resSecondUser = await getVideoChangeOwnershipList(server.url, secondUserAccessToken) + + expect(resSecondUser.body.total).to.equal(1) + expect(resSecondUser.body.data).to.be.an('array') + expect(resSecondUser.body.data.length).to.equal(1) + }) + + it('Should not be possible to refuse the change of ownership from first user', async function () { + this.timeout(10000) + + await refuseChangeOwnership(server.url, firstUserAccessToken, lastRequestChangeOwnershipId, 403) + }) + + it('Should be possible to refuse the change of ownership from second user', async function () { + this.timeout(10000) + + await refuseChangeOwnership(server.url, secondUserAccessToken, lastRequestChangeOwnershipId) + }) + + it('Should send a new request to change ownership of a video', async function () { + this.timeout(15000) + + await changeVideoOwnership(server.url, firstUserAccessToken, server.video.id, secondUser.username) + }) + + it('Should return two requests to change ownership for the second user', async function () { + const resFirstUser = await getVideoChangeOwnershipList(server.url, firstUserAccessToken) + + expect(resFirstUser.body.total).to.equal(0) + expect(resFirstUser.body.data).to.be.an('array') + expect(resFirstUser.body.data.length).to.equal(0) + + const resSecondUser = await getVideoChangeOwnershipList(server.url, secondUserAccessToken) + + expect(resSecondUser.body.total).to.equal(2) + expect(resSecondUser.body.data).to.be.an('array') + expect(resSecondUser.body.data.length).to.equal(2) + + lastRequestChangeOwnershipId = resSecondUser.body.data[0].id + }) + + it('Should not be possible to accept the change of ownership from first user', async function () { + this.timeout(10000) + + const secondUserInformationResponse = await getMyUserInformation(server.url, secondUserAccessToken) + const secondUserInformation: User = secondUserInformationResponse.body + const channelId = secondUserInformation.videoChannels[0].id + await acceptChangeOwnership(server.url, firstUserAccessToken, lastRequestChangeOwnershipId, channelId, 403) + }) + + it('Should be possible to accept the change of ownership from second user', async function () { + this.timeout(10000) + + const secondUserInformationResponse = await getMyUserInformation(server.url, secondUserAccessToken) + const secondUserInformation: User = secondUserInformationResponse.body + const channelId = secondUserInformation.videoChannels[0].id + await acceptChangeOwnership(server.url, secondUserAccessToken, lastRequestChangeOwnershipId, channelId) + }) + + after(async function () { + killallServers([server]) + }) +}) + +describe('Test video change ownership - quota too small', function () { + let server: ServerInfo = undefined + const firstUser = { + username: 'first', + password: 'My great password' + } + const secondUser = { + username: 'second', + password: 'My other password' + } + let firstUserAccessToken = '' + let secondUserAccessToken = '' + let lastRequestChangeOwnershipId = undefined + + before(async function () { + this.timeout(50000) + + // Run one server + await flushTests() + server = await runServer(1) + await setAccessTokensToServers([server]) + + const videoQuota = 42000000 + const limitedVideoQuota = 10 + await createUser(server.url, server.accessToken, firstUser.username, firstUser.password, videoQuota) + await createUser(server.url, server.accessToken, secondUser.username, secondUser.password, limitedVideoQuota) + + firstUserAccessToken = await userLogin(server, firstUser) + secondUserAccessToken = await userLogin(server, secondUser) + + // Upload some videos on the server + const video1Attributes = { + name: 'my super name', + description: 'my super description' + } + await uploadVideo(server.url, firstUserAccessToken, video1Attributes) + + await waitJobs(server) + + const res = await getVideosList(server.url) + const videos = res.body.data + + expect(videos.length).to.equal(1) + + server.video = videos.find(video => video.name === 'my super name') + }) + + it('Should send a request to change ownership of a video', async function () { + this.timeout(15000) + + await changeVideoOwnership(server.url, firstUserAccessToken, server.video.id, secondUser.username) + }) + + it('Should only return a request to change ownership for the second user', async function () { + const resFirstUser = await getVideoChangeOwnershipList(server.url, firstUserAccessToken) + + expect(resFirstUser.body.total).to.equal(0) + expect(resFirstUser.body.data).to.be.an('array') + expect(resFirstUser.body.data.length).to.equal(0) + + const resSecondUser = await getVideoChangeOwnershipList(server.url, secondUserAccessToken) + + expect(resSecondUser.body.total).to.equal(1) + expect(resSecondUser.body.data).to.be.an('array') + expect(resSecondUser.body.data.length).to.equal(1) + + lastRequestChangeOwnershipId = resSecondUser.body.data[0].id + }) + + it('Should not be possible to accept the change of ownership from second user because of exceeded quota', async function () { + this.timeout(10000) + + const secondUserInformationResponse = await getMyUserInformation(server.url, secondUserAccessToken) + const secondUserInformation: User = secondUserInformationResponse.body + const channelId = secondUserInformation.videoChannels[0].id + await acceptChangeOwnership(server.url, secondUserAccessToken, lastRequestChangeOwnershipId, channelId, 403) + }) + + after(async function () { + killallServers([server]) + }) +}) diff --git a/server/tests/utils/index.ts b/server/tests/utils/index.ts index 391db18cf..897389824 100644 --- a/server/tests/utils/index.ts +++ b/server/tests/utils/index.ts @@ -13,5 +13,6 @@ export * from './videos/video-abuses' export * from './videos/video-blacklist' export * from './videos/video-channels' export * from './videos/videos' +export * from './videos/video-change-ownership' export * from './feeds/feeds' export * from './search/videos' diff --git a/server/tests/utils/videos/video-change-ownership.ts b/server/tests/utils/videos/video-change-ownership.ts new file mode 100644 index 000000000..f288692ea --- /dev/null +++ b/server/tests/utils/videos/video-change-ownership.ts @@ -0,0 +1,54 @@ +import * as request from 'supertest' + +function changeVideoOwnership (url: string, token: string, videoId: number | string, username) { + const path = '/api/v1/videos/' + videoId + '/give-ownership' + + return request(url) + .post(path) + .set('Accept', 'application/json') + .set('Authorization', 'Bearer ' + token) + .send({ username }) + .expect(204) +} + +function getVideoChangeOwnershipList (url: string, token: string) { + const path = '/api/v1/videos/ownership' + + return request(url) + .get(path) + .query({ sort: '-createdAt' }) + .set('Accept', 'application/json') + .set('Authorization', 'Bearer ' + token) + .expect(200) + .expect('Content-Type', /json/) +} + +function acceptChangeOwnership (url: string, token: string, ownershipId: string, channelId: number, expectedStatus = 204) { + const path = '/api/v1/videos/ownership/' + ownershipId + '/accept' + + return request(url) + .post(path) + .set('Accept', 'application/json') + .set('Authorization', 'Bearer ' + token) + .send({ channelId }) + .expect(expectedStatus) +} + +function refuseChangeOwnership (url: string, token: string, ownershipId: string, expectedStatus = 204) { + const path = '/api/v1/videos/ownership/' + ownershipId + '/refuse' + + return request(url) + .post(path) + .set('Accept', 'application/json') + .set('Authorization', 'Bearer ' + token) + .expect(expectedStatus) +} + +// --------------------------------------------------------------------------- + +export { + changeVideoOwnership, + getVideoChangeOwnershipList, + acceptChangeOwnership, + refuseChangeOwnership +} diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index 142a0474b..64ad3e9b9 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts @@ -12,5 +12,6 @@ export enum UserRight { REMOVE_ANY_VIDEO, REMOVE_ANY_VIDEO_CHANNEL, REMOVE_ANY_VIDEO_COMMENT, - UPDATE_ANY_VIDEO + UPDATE_ANY_VIDEO, + CHANGE_VIDEO_OWNERSHIP } diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index f1a3d52e1..90a0e3053 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -11,6 +11,8 @@ export * from './blacklist/video-blacklist-update.model' export * from './channel/video-channel-create.model' export * from './channel/video-channel-update.model' export * from './channel/video-channel.model' +export * from './video-change-ownership.model' +export * from './video-change-ownership-create.model' export * from './video-create.model' export * from './video-privacy.enum' export * from './video-rate.type' diff --git a/shared/models/videos/video-change-ownership-accept.model.ts b/shared/models/videos/video-change-ownership-accept.model.ts new file mode 100644 index 000000000..f27247633 --- /dev/null +++ b/shared/models/videos/video-change-ownership-accept.model.ts @@ -0,0 +1,3 @@ +export interface VideoChangeOwnershipAccept { + channelId: number +} diff --git a/shared/models/videos/video-change-ownership-create.model.ts b/shared/models/videos/video-change-ownership-create.model.ts new file mode 100644 index 000000000..40fcca285 --- /dev/null +++ b/shared/models/videos/video-change-ownership-create.model.ts @@ -0,0 +1,3 @@ +export interface VideoChangeOwnershipCreate { + username: string +} diff --git a/shared/models/videos/video-change-ownership.model.ts b/shared/models/videos/video-change-ownership.model.ts new file mode 100644 index 000000000..0d735c798 --- /dev/null +++ b/shared/models/videos/video-change-ownership.model.ts @@ -0,0 +1,21 @@ +import { Account } from '../actors' + +export interface VideoChangeOwnership { + id: number + status: VideoChangeOwnershipStatus + initiatorAccount: Account + nextOwnerAccount: Account + video: { + id: number + name: string + uuid: string + url: string + } + createdAt: Date +} + +export enum VideoChangeOwnershipStatus { + WAITING = 'WAITING', + ACCEPTED = 'ACCEPTED', + REFUSED = 'REFUSED' +}