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 @@
+
+
\ 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'
+}