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 index 0aa4c32ee..7437b939a 100644 --- 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 @@ -53,7 +53,7 @@ export class VideoChangeOwnershipComponent extends FormReactive implements OnIni const query = event.query this.userService.autocomplete(query) .subscribe( - (usernames) => { + usernames => { this.usernamePropositions = usernames }, @@ -67,7 +67,7 @@ export class VideoChangeOwnershipComponent extends FormReactive implements OnIni this.videoOwnershipService .changeOwnership(this.video.id, username) .subscribe( - () => this.notificationsService.success(this.i18n('Success'), this.i18n('Ownership changed.')), + () => this.notificationsService.success(this.i18n('Success'), this.i18n('Ownership change request sent.')), err => this.notificationsService.error(this.i18n('Error'), err.message) ) diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index faba7e208..07edf3727 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -229,7 +229,7 @@ function getUser (req: express.Request, res: express.Response, next: express.Nex } async function autocompleteUsers (req: express.Request, res: express.Response, next: express.NextFunction) { - const resultList = await UserModel.autocomplete(req.query.search as string) + const resultList = await UserModel.autoComplete(req.query.search as string) return res.json(resultList) } diff --git a/server/controllers/api/videos/ownership.ts b/server/controllers/api/videos/ownership.ts index fc42f5fff..d26ed6cfc 100644 --- a/server/controllers/api/videos/ownership.ts +++ b/server/controllers/api/videos/ownership.ts @@ -14,9 +14,11 @@ import { 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 { VideoChangeOwnershipStatus, VideoPrivacy, VideoState } from '../../../../shared/models/videos' import { VideoChannelModel } from '../../../models/video/video-channel' import { getFormattedObjects } from '../../../helpers/utils' +import { changeVideoChannelShare } from '../../../lib/activitypub' +import { sendUpdateVideo } from '../../../lib/activitypub/send' const ownershipVideoRouter = express.Router() @@ -59,8 +61,8 @@ async function giveVideoOwnership (req: express.Request, res: express.Response) 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({ + await sequelizeTypescript.transaction(t => { + return VideoChangeOwnershipModel.findOrCreate({ where: { initiatorAccountId: initiatorAccount.id, nextOwnerAccountId: nextOwner.id, @@ -72,11 +74,14 @@ async function giveVideoOwnership (req: express.Request, res: express.Response) nextOwnerAccountId: nextOwner.id, videoId: videoInstance.id, status: VideoChangeOwnershipStatus.WAITING - } + }, + transaction: t }) - logger.info('Ownership change for video %s created.', videoInstance.name) - return res.type('json').status(204).end() + }) + + 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) { @@ -97,11 +102,19 @@ async function acceptOwnership (req: express.Request, res: express.Response) { const targetVideo = videoChangeOwnership.Video const channel = res.locals.videoChannel as VideoChannelModel - targetVideo.set('channelId', channel.id) + const oldVideoChannel = await VideoChannelModel.loadByIdAndPopulateAccount(targetVideo.channelId) + + targetVideo.set('channelId', channel.id) + const targetVideoUpdated = await targetVideo.save({ transaction: t }) + targetVideoUpdated.VideoChannel = channel + + if (targetVideoUpdated.privacy !== VideoPrivacy.PRIVATE && targetVideoUpdated.state === VideoState.PUBLISHED) { + await changeVideoChannelShare(targetVideoUpdated, oldVideoChannel, t) + await sendUpdateVideo(targetVideoUpdated, t, oldVideoChannel.Account.Actor) + } - await targetVideo.save() videoChangeOwnership.set('status', VideoChangeOwnershipStatus.ACCEPTED) - await videoChangeOwnership.save() + await videoChangeOwnership.save({ transaction: t }) return res.sendStatus(204) }) @@ -110,8 +123,10 @@ async function acceptOwnership (req: express.Request, res: express.Response) { 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() + await videoChangeOwnership.save({ transaction: t }) + return res.sendStatus(204) }) } diff --git a/server/helpers/custom-validators/activitypub/undo.ts b/server/helpers/custom-validators/activitypub/undo.ts index a2831b0bf..f50f224fa 100644 --- a/server/helpers/custom-validators/activitypub/undo.ts +++ b/server/helpers/custom-validators/activitypub/undo.ts @@ -1,13 +1,15 @@ import { isActorFollowActivityValid } from './actor' import { isBaseActivityValid } from './misc' import { isDislikeActivityValid, isLikeActivityValid } from './rate' +import { isAnnounceActivityValid } from './announce' function isUndoActivityValid (activity: any) { return isBaseActivityValid(activity, 'Undo') && ( isActorFollowActivityValid(activity.object) || isLikeActivityValid(activity.object) || - isDislikeActivityValid(activity.object) + isDislikeActivityValid(activity.object) || + isAnnounceActivityValid(activity.object) ) } diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts index eab9e3d61..1c1de8827 100644 --- a/server/lib/activitypub/process/process-undo.ts +++ b/server/lib/activitypub/process/process-undo.ts @@ -104,17 +104,19 @@ function processUndoFollow (actorUrl: string, followActivity: ActivityFollow) { function processUndoAnnounce (actorUrl: string, announceActivity: ActivityAnnounce) { return sequelizeTypescript.transaction(async t => { - const byAccount = await AccountModel.loadByUrl(actorUrl, t) - if (!byAccount) throw new Error('Unknown account ' + actorUrl) + const byActor = await ActorModel.loadByUrl(actorUrl, t) + if (!byActor) throw new Error('Unknown actor ' + actorUrl) const share = await VideoShareModel.loadByUrl(announceActivity.id, t) - if (!share) throw new Error(`'Unknown video share ${announceActivity.id}.`) + if (!share) throw new Error(`Unknown video share ${announceActivity.id}.`) + + if (share.actorId !== byActor.id) throw new Error(`${share.url} is not shared by ${byActor.url}.`) await share.destroy({ transaction: t }) if (share.Video.isOwned()) { // Don't resend the activity to the sender - const exceptions = [ byAccount.Actor ] + const exceptions = [ byActor ] await forwardVideoRelatedActivity(announceActivity, t, exceptions, share.Video) } diff --git a/server/lib/activitypub/send/send-announce.ts b/server/lib/activitypub/send/send-announce.ts index 1ab05ca3c..352813d73 100644 --- a/server/lib/activitypub/send/send-announce.ts +++ b/server/lib/activitypub/send/send-announce.ts @@ -20,7 +20,10 @@ async function sendVideoAnnounce (byActor: ActorModel, videoShare: VideoShareMod logger.info('Creating job to send announce %s.', videoShare.url) - return broadcastToFollowers(data, byActor, [ byActor ], t) + const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t) + const followersException = [ byActor ] + + return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, followersException) } function announceActivityData (url: string, byActor: ActorModel, object: string, audience?: ActivityAudience): ActivityAnnounce { diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts index 17d4f185c..6f1d80898 100644 --- a/server/lib/activitypub/send/send-update.ts +++ b/server/lib/activitypub/send/send-update.ts @@ -10,13 +10,19 @@ import { getUpdateActivityPubUrl } from '../url' import { broadcastToFollowers } from './utils' import { audiencify, getAudience } from '../audience' import { logger } from '../../../helpers/logger' +import { videoFeedsValidator } from '../../../middlewares/validators' +import { VideoCaptionModel } from '../../../models/video/video-caption' -async function sendUpdateVideo (video: VideoModel, t: Transaction) { +async function sendUpdateVideo (video: VideoModel, t: Transaction, overrodeByActor?: ActorModel) { logger.info('Creating job to update video %s.', video.url) - const byActor = video.VideoChannel.Account.Actor + const byActor = overrodeByActor ? overrodeByActor : video.VideoChannel.Account.Actor const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString()) + + // Needed to build the AP object + if (!video.VideoCaptions) video.VideoCaptions = await video.$get('VideoCaptions') as VideoCaptionModel[] + const videoObject = video.toActivityPubObject() const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC) diff --git a/server/lib/activitypub/share.ts b/server/lib/activitypub/share.ts index fe3d73e9b..3ff60a97c 100644 --- a/server/lib/activitypub/share.ts +++ b/server/lib/activitypub/share.ts @@ -22,6 +22,8 @@ async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) } async function changeVideoChannelShare (video: VideoModel, oldVideoChannel: VideoChannelModel, t: Transaction) { + logger.info('Updating video channel of video %s: %s -> %s.', video.uuid, oldVideoChannel.name, video.VideoChannel.name) + await undoShareByVideoChannel(video, oldVideoChannel, t) await shareByVideoChannel(video, t) diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 4b13e47a0..680b1d52d 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -23,13 +23,13 @@ import { isUserAutoPlayVideoValid, isUserBlockedReasonValid, isUserBlockedValid, - isUserNSFWPolicyValid, isUserEmailVerifiedValid, + isUserNSFWPolicyValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, - isUserVideoQuotaValid, - isUserVideoQuotaDailyValid + isUserVideoQuotaDailyValid, + isUserVideoQuotaValid } from '../../helpers/custom-validators/users' import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto' import { OAuthTokenModel } from '../oauth/oauth-token' @@ -39,7 +39,6 @@ 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' @@ -296,6 +295,20 @@ export class UserModel extends Model { } } + static autoComplete (search: string) { + const query = { + where: { + username: { + [ Sequelize.Op.like ]: `%${search}%` + } + }, + limit: 10 + } + + return UserModel.findAll(query) + .then(u => u.map(u => u.username)) + } + hasRight (right: UserRight) { return hasUserRight(this.role, right) } @@ -394,15 +407,4 @@ 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 index c9cff5054..48c07728f 100644 --- a/server/models/video/video-change-ownership.ts +++ b/server/models/video/video-change-ownership.ts @@ -39,7 +39,9 @@ enum ScopeNames { { model: () => VideoModel, required: true, - include: [{ model: () => VideoFileModel }] + include: [ + { model: () => VideoFileModel } + ] } ] } @@ -94,15 +96,17 @@ export class VideoChangeOwnershipModel extends Model Video: VideoModel static listForApi (nextOwnerId: number, start: number, count: number, sort: string) { - return VideoChangeOwnershipModel.scope(ScopeNames.FULL).findAndCountAll({ + const query = { offset: start, limit: count, order: getSort(sort), where: { nextOwnerAccountId: nextOwnerId } - }) - .then(({ rows, count }) => ({ total: count, data: rows })) + } + + return VideoChangeOwnershipModel.scope(ScopeNames.FULL).findAndCountAll(query) + .then(({ rows, count }) => ({ total: count, data: rows })) } static load (id: number) { diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index 475530daf..f4586917e 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts @@ -296,6 +296,12 @@ export class VideoChannelModel extends Model { }) } + static loadByIdAndPopulateAccount (id: number) { + return VideoChannelModel.unscoped() + .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) + .findById(id) + } + static loadByIdAndAccount (id: number, accountId: number) { const query = { where: { @@ -304,13 +310,13 @@ export class VideoChannelModel extends Model { } } - return VideoChannelModel + return VideoChannelModel.unscoped() .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) .findOne(query) } static loadAndPopulateAccount (id: number) { - return VideoChannelModel + return VideoChannelModel.unscoped() .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) .findById(id) } @@ -365,7 +371,7 @@ export class VideoChannelModel extends Model { ] } - return VideoChannelModel + return VideoChannelModel.unscoped() .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) .findOne(query) } @@ -390,7 +396,7 @@ export class VideoChannelModel extends Model { ] } - return VideoChannelModel + return VideoChannelModel.unscoped() .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) .findOne(query) } @@ -402,7 +408,7 @@ export class VideoChannelModel extends Model { ] } - return VideoChannelModel + return VideoChannelModel.unscoped() .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ]) .findById(id, options) } diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index 8286ff356..a328a49c1 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts @@ -5,6 +5,7 @@ import './video-abuse' import './video-blacklist' import './video-blacklist-management' import './video-captions' +import './vidoe-change-ownership' import './video-channels' import './video-comments' import './video-description' diff --git a/server/tests/api/videos/video-change-ownership.ts b/server/tests/api/videos/video-change-ownership.ts index 275be40be..1578a471d 100644 --- a/server/tests/api/videos/video-change-ownership.ts +++ b/server/tests/api/videos/video-change-ownership.ts @@ -5,7 +5,7 @@ import 'mocha' import { acceptChangeOwnership, changeVideoOwnership, - createUser, + createUser, doubleFollow, flushAndRunMultipleServers, flushTests, getMyUserInformation, getVideoChangeOwnershipList, @@ -16,15 +16,17 @@ import { ServerInfo, setAccessTokensToServers, uploadVideo, - userLogin + userLogin, + getVideo } from '../../utils' import { waitJobs } from '../../utils/server/jobs' import { User } from '../../../../shared/models/users' +import { VideoDetails } from '../../../../shared/models/videos' const expect = chai.expect describe('Test video change ownership - nominal', function () { - let server: ServerInfo = undefined + let servers: ServerInfo[] = [] const firstUser = { username: 'first', password: 'My great password' @@ -40,43 +42,44 @@ describe('Test video change ownership - nominal', function () { before(async function () { this.timeout(50000) - // Run one server - await flushTests() - server = await runServer(1) - await setAccessTokensToServers([server]) + servers = await flushAndRunMultipleServers(2) + await setAccessTokensToServers(servers) 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) + await createUser(servers[0].url, servers[0].accessToken, firstUser.username, firstUser.password, videoQuota) + await createUser(servers[0].url, servers[0].accessToken, secondUser.username, secondUser.password, videoQuota) - firstUserAccessToken = await userLogin(server, firstUser) - secondUserAccessToken = await userLogin(server, secondUser) + firstUserAccessToken = await userLogin(servers[0], firstUser) + secondUserAccessToken = await userLogin(servers[0], secondUser) - // Upload some videos on the server - const video1Attributes = { + const videoAttributes = { name: 'my super name', description: 'my super description' } - await uploadVideo(server.url, firstUserAccessToken, video1Attributes) + await uploadVideo(servers[0].url, firstUserAccessToken, videoAttributes) - await waitJobs(server) + await waitJobs(servers) - const res = await getVideosList(server.url) + const res = await getVideosList(servers[0].url) const videos = res.body.data expect(videos.length).to.equal(1) - server.video = videos.find(video => video.name === 'my super name') + const video = videos.find(video => video.name === 'my super name') + expect(video.channel.name).to.equal('first_channel') + servers[0].video = video + + await doubleFollow(servers[0], servers[1]) }) it('Should not have video change ownership', async function () { - const resFirstUser = await getVideoChangeOwnershipList(server.url, firstUserAccessToken) + const resFirstUser = await getVideoChangeOwnershipList(servers[0].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) + const resSecondUser = await getVideoChangeOwnershipList(servers[0].url, secondUserAccessToken) expect(resSecondUser.body.total).to.equal(0) expect(resSecondUser.body.data).to.be.an('array') @@ -86,17 +89,17 @@ describe('Test video change ownership - nominal', function () { 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) + await changeVideoOwnership(servers[0].url, firstUserAccessToken, servers[0].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) + const resFirstUser = await getVideoChangeOwnershipList(servers[0].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) + const resSecondUser = await getVideoChangeOwnershipList(servers[0].url, secondUserAccessToken) expect(resSecondUser.body.total).to.equal(1) expect(resSecondUser.body.data).to.be.an('array') @@ -108,13 +111,13 @@ describe('Test video change ownership - nominal', function () { 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) + await changeVideoOwnership(servers[0].url, firstUserAccessToken, servers[0].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) + const resSecondUser = await getVideoChangeOwnershipList(servers[0].url, secondUserAccessToken) expect(resSecondUser.body.total).to.equal(1) expect(resSecondUser.body.data).to.be.an('array') @@ -124,29 +127,29 @@ describe('Test video change ownership - nominal', function () { 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) + await refuseChangeOwnership(servers[0].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) + await refuseChangeOwnership(servers[0].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) + await changeVideoOwnership(servers[0].url, firstUserAccessToken, servers[0].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) + const resFirstUser = await getVideoChangeOwnershipList(servers[0].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) + const resSecondUser = await getVideoChangeOwnershipList(servers[0].url, secondUserAccessToken) expect(resSecondUser.body.total).to.equal(2) expect(resSecondUser.body.data).to.be.an('array') @@ -158,23 +161,37 @@ describe('Test video change ownership - nominal', function () { 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 secondUserInformationResponse = await getMyUserInformation(servers[0].url, secondUserAccessToken) const secondUserInformation: User = secondUserInformationResponse.body const channelId = secondUserInformation.videoChannels[0].id - await acceptChangeOwnership(server.url, firstUserAccessToken, lastRequestChangeOwnershipId, channelId, 403) + await acceptChangeOwnership(servers[0].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 secondUserInformationResponse = await getMyUserInformation(servers[0].url, secondUserAccessToken) const secondUserInformation: User = secondUserInformationResponse.body const channelId = secondUserInformation.videoChannels[0].id - await acceptChangeOwnership(server.url, secondUserAccessToken, lastRequestChangeOwnershipId, channelId) + await acceptChangeOwnership(servers[0].url, secondUserAccessToken, lastRequestChangeOwnershipId, channelId) + + await waitJobs(servers) + }) + + it('Should have video channel updated', async function () { + for (const server of servers) { + const res = await getVideo(server.url, servers[0].video.uuid) + + const video: VideoDetails = res.body + + expect(video.name).to.equal('my super name') + expect(video.channel.displayName).to.equal('Main second channel') + expect(video.channel.name).to.equal('second_channel') + } }) after(async function () { - killallServers([server]) + killallServers(servers) }) })