diff --git a/client/src/app/shared/actor/actor.model.ts b/client/src/app/shared/actor/actor.model.ts index 56ff780b7..37d84cb6e 100644 --- a/client/src/app/shared/actor/actor.model.ts +++ b/client/src/app/shared/actor/actor.model.ts @@ -1,6 +1,6 @@ import { Actor as ActorServer } from '../../../../../shared/models/actors/actor.model' -import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' import { Avatar } from '../../../../../shared/models/avatars/avatar.model' +import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' export abstract class Actor implements ActorServer { id: number @@ -41,8 +41,8 @@ export abstract class Actor implements ActorServer { this.host = hash.host this.followingCount = hash.followingCount this.followersCount = hash.followersCount - this.createdAt = new Date(hash.createdAt.toString()) - this.updatedAt = new Date(hash.updatedAt.toString()) + this.createdAt = new Date(hash.createdAt) + this.updatedAt = new Date(hash.updatedAt) this.avatar = hash.avatar this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this) diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts index 39826d71e..ad2929db5 100644 --- a/client/src/app/shared/video/video-edit.model.ts +++ b/client/src/app/shared/video/video-edit.model.ts @@ -10,7 +10,7 @@ export class VideoEdit { tags: string[] nsfw: boolean commentsEnabled: boolean - channel: number + channelId: number privacy: VideoPrivacy support: string thumbnailfile?: any @@ -32,7 +32,7 @@ export class VideoEdit { this.tags = videoDetails.tags this.nsfw = videoDetails.nsfw this.commentsEnabled = videoDetails.commentsEnabled - this.channel = videoDetails.channel.id + this.channelId = videoDetails.channel.id this.privacy = videoDetails.privacy.id this.support = videoDetails.support this.thumbnailUrl = videoDetails.thumbnailUrl @@ -57,7 +57,7 @@ export class VideoEdit { tags: this.tags, nsfw: this.nsfw, commentsEnabled: this.commentsEnabled, - channelId: this.channel, + channelId: this.channelId, privacy: this.privacy } } diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index f56eecaeb..48d562f9c 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts @@ -45,6 +45,16 @@ export class Video implements VideoServerModel { avatar: Avatar } + channel: { + id: number + uuid: string + name: string + displayName: string + url: string + host: string + avatar: Avatar + } + private static createDurationString (duration: number) { const hours = Math.floor(duration / 3600) const minutes = Math.floor(duration % 3600 / 60) diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index b45777c55..cd8539b41 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts @@ -67,6 +67,7 @@ export class VideoService { language, support, description, + channelId: video.channelId, privacy: video.privacy, tags: video.tags, nsfw: video.nsfw, diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.html b/client/src/app/videos/+video-edit/shared/video-edit.component.html index 9cd3454a0..77b554ad5 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.html @@ -33,7 +33,7 @@
-
+
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss index cf64ff589..58ed5ab98 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss @@ -5,10 +5,6 @@ @include peertube-select-container(auto); } -.peertube-select-disabled-container { - @include peertube-select-disabled-container(auto); -} - .form-group-checkbox { my-help { margin-left: 5px } } diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.ts b/client/src/app/videos/+video-edit/shared/video-edit.component.ts index 6ab1a4a24..77e984855 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.ts @@ -75,7 +75,7 @@ export class VideoEditComponent implements OnInit { this.form.addControl('name', new FormControl('', VIDEO_NAME.VALIDATORS)) this.form.addControl('privacy', new FormControl('', VIDEO_PRIVACY.VALIDATORS)) - this.form.addControl('channelId', new FormControl({ value: '', disabled: true })) + this.form.addControl('channelId', new FormControl('', VIDEO_CHANNEL.VALIDATORS)) this.form.addControl('nsfw', new FormControl(false)) this.form.addControl('commentsEnabled', new FormControl(true)) this.form.addControl('category', new FormControl('', VIDEO_CATEGORY.VALIDATORS)) diff --git a/client/src/app/videos/+video-edit/video-add.component.ts b/client/src/app/videos/+video-edit/video-add.component.ts index ae5548897..fa967018d 100644 --- a/client/src/app/videos/+video-edit/video-add.component.ts +++ b/client/src/app/videos/+video-edit/video-add.component.ts @@ -220,7 +220,7 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy const video = new VideoEdit() video.patch(this.form.value) - video.channel = this.firstStepChannelId + video.channelId = this.firstStepChannelId video.id = this.videoUploadedIds.id video.uuid = this.videoUploadedIds.uuid diff --git a/client/src/app/videos/+video-edit/video-update.component.ts b/client/src/app/videos/+video-edit/video-update.component.ts index 6cd204f72..73e2764c6 100644 --- a/client/src/app/videos/+video-edit/video-update.component.ts +++ b/client/src/app/videos/+video-edit/video-update.component.ts @@ -9,9 +9,9 @@ import { ServerService } from '../../core' import { AuthService } from '../../core/auth' import { FormReactive } from '../../shared' import { ValidatorMessage } from '../../shared/forms/form-validators/validator-message' -import { populateAsyncUserVideoChannels } from '../../shared/misc/utils' import { VideoEdit } from '../../shared/video/video-edit.model' import { VideoService } from '../../shared/video/video.service' +import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils' @Component({ selector: 'my-videos-update', @@ -64,12 +64,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { video => { this.video = new VideoEdit(video) - this.userVideoChannels = [ - { - id: video.channel.id, - label: video.channel.displayName - } - ] + populateAsyncUserVideoChannels(this.authService, this.userVideoChannels) + .catch(err => console.error(err)) // We cannot set private a video that was not private if (video.privacy.id !== VideoPrivacy.PRIVATE) { diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 4b3198a74..c07430e6c 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -19,7 +19,12 @@ import { VIDEO_MIMETYPE_EXT, VIDEO_PRIVACIES } from '../../../initializers' -import { fetchRemoteVideoDescription, getVideoActivityPubUrl, shareVideoByServerAndChannel } from '../../../lib/activitypub' +import { + changeVideoChannelShare, + fetchRemoteVideoDescription, + getVideoActivityPubUrl, + shareVideoByServerAndChannel +} from '../../../lib/activitypub' import { sendCreateVideo, sendCreateView, sendUpdateVideo } from '../../../lib/activitypub/send' import { JobQueue } from '../../../lib/job-queue' import { Redis } from '../../../lib/redis' @@ -305,6 +310,7 @@ async function updateVideo (req: express.Request, res: express.Response) { const sequelizeOptions = { transaction: t } + const oldVideoChannel = videoInstance.VideoChannel if (videoInfoToUpdate.name !== undefined) videoInstance.set('name', videoInfoToUpdate.name) if (videoInfoToUpdate.category !== undefined) videoInstance.set('category', videoInfoToUpdate.category) @@ -325,17 +331,24 @@ async function updateVideo (req: express.Request, res: express.Response) { const videoInstanceUpdated = await videoInstance.save(sequelizeOptions) + // Video tags update? if (videoInfoToUpdate.tags) { const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t) - await videoInstance.$set('Tags', tagInstances, sequelizeOptions) - videoInstance.Tags = tagInstances + await videoInstanceUpdated.$set('Tags', tagInstances, sequelizeOptions) + videoInstanceUpdated.Tags = tagInstances + } + + // Video channel update? + if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) { + await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel) + videoInstance.VideoChannel = res.locals.videoChannel + + if (wasPrivateVideo === false) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t) } // Now we'll update the video's meta data to our friends - if (wasPrivateVideo === false) { - await sendUpdateVideo(videoInstanceUpdated, t) - } + if (wasPrivateVideo === false) await sendUpdateVideo(videoInstanceUpdated, t) // Video is not private anymore, send a create action to remote servers if (wasPrivateVideo === true && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE) { diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 23d2d8ac6..c35db49ac 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -14,6 +14,7 @@ import { } from '../../initializers' import { VideoModel } from '../../models/video/video' import { exists, isArray, isFileValid } from './misc' +import { VideoChannelModel } from '../../models/video/video-channel' const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES @@ -124,6 +125,20 @@ async function isVideoExist (id: string, res: Response) { return true } +async function isVideoChannelOfAccountExist (channelId: number, accountId: number, res: Response) { + const videoChannel = await VideoChannelModel.loadByIdAndAccount(channelId, accountId) + if (!videoChannel) { + res.status(400) + .json({ error: 'Unknown video video channel for this account.' }) + .end() + + return false + } + + res.locals.videoChannel = videoChannel + return true +} + // --------------------------------------------------------------------------- export { @@ -146,5 +161,6 @@ export { isVideoFileSizeValid, isVideoExist, isVideoImage, + isVideoChannelOfAccountExist, isVideoSupportValid } diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts index b0cf9bb17..5773fc34f 100644 --- a/server/lib/activitypub/actor.ts +++ b/server/lib/activitypub/actor.ts @@ -353,7 +353,7 @@ async function saveVideoChannel (actor: ActorModel, result: FetchRemoteActorResu return videoChannelCreated } -async function refreshActorIfNeeded (actor: ActorModel) { +async function refreshActorIfNeeded (actor: ActorModel): Promise { if (!actor.isOutdated()) return actor try { diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts index 565e70289..9b024d15f 100644 --- a/server/lib/activitypub/process/process-undo.ts +++ b/server/lib/activitypub/process/process-undo.ts @@ -1,4 +1,4 @@ -import { ActivityFollow, ActivityLike, ActivityUndo } from '../../../../shared/models/activitypub' +import { ActivityAnnounce, ActivityFollow, ActivityLike, ActivityUndo } from '../../../../shared/models/activitypub' import { DislikeObject } from '../../../../shared/models/activitypub/objects' import { getActorUrl } from '../../../helpers/activitypub' import { retryTransactionWrapper } from '../../../helpers/database-utils' @@ -10,6 +10,7 @@ import { ActorModel } from '../../../models/activitypub/actor' import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { forwardActivity } from '../send/misc' import { getOrCreateAccountAndVideoAndChannel } from '../videos' +import { VideoShareModel } from '../../../models/video/video-share' async function processUndoActivity (activity: ActivityUndo) { const activityToUndo = activity.object @@ -22,6 +23,8 @@ async function processUndoActivity (activity: ActivityUndo) { return processUndoDislike(actorUrl, activity) } else if (activityToUndo.type === 'Follow') { return processUndoFollow(actorUrl, activityToUndo) + } else if (activityToUndo.type === 'Announce') { + return processUndoAnnounce(actorUrl, activityToUndo) } logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id }) @@ -123,3 +126,23 @@ function undoFollow (actorUrl: string, followActivity: ActivityFollow) { return undefined }) } + +function processUndoAnnounce (actorUrl: string, announceActivity: ActivityAnnounce) { + const options = { + arguments: [ actorUrl, announceActivity ], + errorMessage: 'Cannot undo announce with many retries.' + } + + return retryTransactionWrapper(undoAnnounce, options) +} + +function undoAnnounce (actorUrl: string, announceActivity: ActivityAnnounce) { + return sequelizeTypescript.transaction(async t => { + const share = await VideoShareModel.loadByUrl(announceActivity.id, t) + if (!share) throw new Error(`'Unknown video share ${announceActivity.id}.`) + + await share.destroy({ transaction: t }) + + return undefined + }) +} diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts index 51e3cc4e3..0dd657c2b 100644 --- a/server/lib/activitypub/process/process-update.ts +++ b/server/lib/activitypub/process/process-update.ts @@ -14,7 +14,7 @@ import { VideoFileModel } from '../../../models/video/video-file' import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor' import { generateThumbnailFromUrl, - getOrCreateAccountAndVideoAndChannel, + getOrCreateAccountAndVideoAndChannel, getOrCreateVideoChannel, videoActivityObjectToDBAttributes, videoFileActivityUrlToDBAttributes } from '../videos' @@ -54,6 +54,10 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) { const res = await getOrCreateAccountAndVideoAndChannel(videoAttributesToUpdate.id) + // Fetch video channel outside the transaction + const newVideoChannelActor = await getOrCreateVideoChannel(videoAttributesToUpdate) + const newVideoChannel = newVideoChannelActor.VideoChannel + logger.debug('Updating remote video "%s".', videoAttributesToUpdate.uuid) let videoInstance = res.video let videoFieldsSave: any @@ -66,12 +70,13 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) { videoFieldsSave = videoInstance.toJSON() + // Check actor has the right to update the video const videoChannel = videoInstance.VideoChannel if (videoChannel.Account.Actor.id !== actor.id) { throw new Error('Account ' + actor.url + ' does not own video channel ' + videoChannel.Actor.url) } - const videoData = await videoActivityObjectToDBAttributes(videoChannel, videoAttributesToUpdate, activity.to) + const videoData = await videoActivityObjectToDBAttributes(newVideoChannel, videoAttributesToUpdate, activity.to) videoInstance.set('name', videoData.name) videoInstance.set('uuid', videoData.uuid) videoInstance.set('url', videoData.url) @@ -87,6 +92,7 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) { videoInstance.set('updatedAt', videoData.updatedAt) videoInstance.set('views', videoData.views) videoInstance.set('privacy', videoData.privacy) + videoInstance.set('channelId', videoData.channelId) await videoInstance.save(sequelizeOptions) diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts index bd49d452e..adee2192f 100644 --- a/server/lib/activitypub/send/send-undo.ts +++ b/server/lib/activitypub/send/send-undo.ts @@ -1,5 +1,12 @@ import { Transaction } from 'sequelize' -import { ActivityAudience, ActivityCreate, ActivityFollow, ActivityLike, ActivityUndo } from '../../../../shared/models/activitypub' +import { + ActivityAnnounce, + ActivityAudience, + ActivityCreate, + ActivityFollow, + ActivityLike, + ActivityUndo +} from '../../../../shared/models/activitypub' import { ActorModel } from '../../../models/activitypub/actor' import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { VideoModel } from '../../../models/video/video' @@ -16,6 +23,8 @@ import { import { createActivityData, createDislikeActivityData } from './send-create' import { followActivityData } from './send-follow' import { likeActivityData } from './send-like' +import { VideoShareModel } from '../../../models/video/video-share' +import { buildVideoAnnounce } from './send-announce' async function sendUndoFollow (actorFollow: ActorFollowModel, t: Transaction) { const me = actorFollow.ActorFollower @@ -58,7 +67,7 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t) const dislikeActivity = createDislikeActivityData(byActor, video) - const object = await createActivityData(undoUrl, byActor, dislikeActivity, t) + const object = await createActivityData(dislikeUrl, byActor, dislikeActivity, t) if (video.isOwned() === false) { const audience = getOriginVideoAudience(video, actorsInvolvedInVideo) @@ -73,12 +82,24 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, followersException) } +async function sendUndoAnnounce (byActor: ActorModel, videoShare: VideoShareModel, video: VideoModel, t: Transaction) { + const undoUrl = getUndoActivityPubUrl(videoShare.url) + + const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t) + const object = await buildVideoAnnounce(byActor, videoShare, video, t) + const data = await undoActivityData(undoUrl, byActor, object, t) + + const followersException = [ byActor ] + return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, followersException) +} + // --------------------------------------------------------------------------- export { sendUndoFollow, sendUndoLike, - sendUndoDislike + sendUndoDislike, + sendUndoAnnounce } // --------------------------------------------------------------------------- @@ -86,7 +107,7 @@ export { async function undoActivityData ( url: string, byActor: ActorModel, - object: ActivityFollow | ActivityLike | ActivityCreate, + object: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce, t: Transaction, audience?: ActivityAudience ): Promise { diff --git a/server/lib/activitypub/share.ts b/server/lib/activitypub/share.ts index f256f8d21..698414867 100644 --- a/server/lib/activitypub/share.ts +++ b/server/lib/activitypub/share.ts @@ -3,16 +3,37 @@ import { VideoPrivacy } from '../../../shared/models/videos' import { getServerActor } from '../../helpers/utils' import { VideoModel } from '../../models/video/video' import { VideoShareModel } from '../../models/video/video-share' -import { sendVideoAnnounce } from './send' +import { sendUndoAnnounce, sendVideoAnnounce } from './send' import { getAnnounceActivityPubUrl } from './url' +import { VideoChannelModel } from '../../models/video/video-channel' async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) { if (video.privacy === VideoPrivacy.PRIVATE) return undefined + return Promise.all([ + shareByServer(video, t), + shareByVideoChannel(video, t) + ]) +} + +async function changeVideoChannelShare (video: VideoModel, oldVideoChannel: VideoChannelModel, t: Transaction) { + await undoShareByVideoChannel(video, oldVideoChannel, t) + + await shareByVideoChannel(video, t) +} + +export { + changeVideoChannelShare, + shareVideoByServerAndChannel +} + +// --------------------------------------------------------------------------- + +async function shareByServer (video: VideoModel, t: Transaction) { const serverActor = await getServerActor() const serverShareUrl = getAnnounceActivityPubUrl(video.url, serverActor) - const serverSharePromise = VideoShareModel.findOrCreate({ + return VideoShareModel.findOrCreate({ defaults: { actorId: serverActor.id, videoId: video.id, @@ -27,9 +48,11 @@ async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) return undefined }) +} +async function shareByVideoChannel (video: VideoModel, t: Transaction) { const videoChannelShareUrl = getAnnounceActivityPubUrl(video.url, video.VideoChannel.Actor) - const videoChannelSharePromise = VideoShareModel.findOrCreate({ + return VideoShareModel.findOrCreate({ defaults: { actorId: video.VideoChannel.actorId, videoId: video.id, @@ -40,17 +63,17 @@ async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) }, transaction: t }).then(([ videoChannelShare, created ]) => { - if (created) return sendVideoAnnounce(serverActor, videoChannelShare, video, t) + if (created) return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t) return undefined }) - - return Promise.all([ - serverSharePromise, - videoChannelSharePromise - ]) } -export { - shareVideoByServerAndChannel +async function undoShareByVideoChannel (video: VideoModel, oldVideoChannel: VideoChannelModel, t: Transaction) { + // Load old share + const oldShare = await VideoShareModel.load(oldVideoChannel.actorId, video.id, t) + if (!oldShare) return new Error('Cannot find old video channel share ' + oldVideoChannel.actorId + ' for video ' + video.id) + + await sendUndoAnnounce(oldVideoChannel.Actor, oldShare, video, t) + await oldShare.destroy({ transaction: t }) } diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index b81acbb35..2899acff3 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -137,6 +137,13 @@ function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObje return attributes } +function getOrCreateVideoChannel (videoObject: VideoTorrentObject) { + const channel = videoObject.attributedTo.find(a => a.type === 'Group') + if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) + + return getOrCreateActorAndServerAndModel(channel.id) +} + async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor: ActorModel) { logger.debug('Adding remote video %s.', videoObject.id) @@ -199,10 +206,7 @@ async function getOrCreateAccountAndVideoAndChannel (videoObject: VideoTorrentOb actor = await getOrCreateActorAndServerAndModel(actorObj.id) } - const channel = videoObject.attributedTo.find(a => a.type === 'Group') - if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) - - const channelActor = await getOrCreateActorAndServerAndModel(channel.id) + const channelActor = await getOrCreateVideoChannel(videoObject) const options = { arguments: [ videoObject, channelActor ], @@ -301,6 +305,7 @@ export { videoActivityObjectToDBAttributes, videoFileActivityUrlToDBAttributes, getOrCreateVideo, + getOrCreateVideoChannel, addVideoShares} // --------------------------------------------------------------------------- diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts index b93dccc50..aa2afb068 100644 --- a/server/middlewares/validators/videos.ts +++ b/server/middlewares/validators/videos.ts @@ -6,6 +6,7 @@ import { isBooleanValid, isIdOrUUIDValid, isIdValid, isUUIDValid, toIntOrNull, t import { isVideoAbuseReasonValid, isVideoCategoryValid, + isVideoChannelOfAccountExist, isVideoDescriptionValid, isVideoExist, isVideoFile, @@ -23,7 +24,6 @@ import { logger } from '../../helpers/logger' import { CONSTRAINTS_FIELDS } from '../../initializers' import { UserModel } from '../../models/account/user' import { VideoModel } from '../../models/video/video' -import { VideoChannelModel } from '../../models/video/video-channel' import { VideoShareModel } from '../../models/video/video-share' import { authenticate } from '../oauth' import { areValidationErrors } from './utils' @@ -75,7 +75,10 @@ const videosAddValidator = [ .optional() .toInt() .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'), - body('channelId').custom(isIdValid).withMessage('Should have correct video channel id'), + body('channelId') + .toInt() + .custom(isIdValid) + .withMessage('Should have correct video channel id'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files }) @@ -86,16 +89,7 @@ const videosAddValidator = [ const videoFile: Express.Multer.File = req.files['videofile'][0] const user = res.locals.oauth.token.User - const videoChannel = await VideoChannelModel.loadByIdAndAccount(req.body.channelId, user.Account.id) - if (!videoChannel) { - res.status(400) - .json({ error: 'Unknown video video channel for this account.' }) - .end() - - return - } - - res.locals.videoChannel = videoChannel + if (!await isVideoChannelOfAccountExist(req.body.channelId, user.Account.id, res)) return const isAble = await user.isAbleToUploadVideo(videoFile) if (isAble === false) { @@ -173,6 +167,10 @@ const videosUpdateValidator = [ .optional() .toBoolean() .custom(isBooleanValid).withMessage('Should have comments enabled boolean'), + body('channelId') + .optional() + .toInt() + .custom(isIdValid).withMessage('Should have correct video channel id'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking videosUpdate parameters', { parameters: req.body }) @@ -184,7 +182,8 @@ const videosUpdateValidator = [ const video = res.locals.video // Check if the user who did the request is able to update the video - if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return if (video.privacy !== VideoPrivacy.PRIVATE && req.body.privacy === VideoPrivacy.PRIVATE) { return res.status(409) @@ -192,6 +191,8 @@ const videosUpdateValidator = [ .end() } + if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user.Account.id, res)) return + return next() } ] diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts index 6f770957f..602cc69b9 100644 --- a/server/models/video/video-share.ts +++ b/server/models/video/video-share.ts @@ -98,6 +98,15 @@ export class VideoShareModel extends Model { }) } + static loadByUrl (url: string, t: Sequelize.Transaction) { + return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({ + where: { + url + }, + transaction: t + }) + } + static loadActorsByShare (videoId: number, t: Sequelize.Transaction) { const query = { where: { diff --git a/server/models/video/video.ts b/server/models/video/video.ts index ea466fccd..fe8c30655 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -130,11 +130,27 @@ enum ScopeNames { } const videoChannelInclude = { - attributes: [ 'name', 'description' ], + attributes: [ 'name', 'description', 'id' ], model: VideoChannelModel.unscoped(), required: true, where: {}, include: [ + { + attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], + model: ActorModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'host' ], + model: ServerModel.unscoped(), + required: false + }, + { + model: AvatarModel.unscoped(), + required: false + } + ] + }, accountInclude ] } @@ -771,12 +787,17 @@ export class VideoModel extends Model { } }, { - preferredUsername: Sequelize.where(Sequelize.col('preferredUsername'), { + preferredUsernameChannel: Sequelize.where(Sequelize.col('VideoChannel->Actor.preferredUsername'), { [ Sequelize.Op.iLike ]: '%' + value + '%' }) }, { - host: Sequelize.where(Sequelize.col('host'), { + preferredUsernameAccount: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor.preferredUsername'), { + [ Sequelize.Op.iLike ]: '%' + value + '%' + }) + }, + { + host: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor->Server.host'), { [ Sequelize.Op.iLike ]: '%' + value + '%' }) } @@ -1043,6 +1064,7 @@ export class VideoModel extends Model { toFormattedJSON (): Video { const formattedAccount = this.VideoChannel.Account.toFormattedJSON() + const formattedVideoChannel = this.VideoChannel.toFormattedJSON() return { id: this.id, @@ -1085,6 +1107,15 @@ export class VideoModel extends Model { url: formattedAccount.url, host: formattedAccount.host, avatar: formattedAccount.avatar + }, + channel: { + id: formattedVideoChannel.id, + uuid: formattedVideoChannel.uuid, + name: formattedVideoChannel.name, + displayName: formattedVideoChannel.displayName, + url: formattedVideoChannel.url, + host: formattedVideoChannel.host, + avatar: formattedVideoChannel.avatar } } } diff --git a/server/tests/api/videos/video-channels.ts b/server/tests/api/videos/video-channels.ts index 585b6a2b5..35c418f7c 100644 --- a/server/tests/api/videos/video-channels.ts +++ b/server/tests/api/videos/video-channels.ts @@ -2,8 +2,8 @@ import * as chai from 'chai' import 'mocha' -import { User } from '../../../../shared/index' -import { doubleFollow, flushAndRunMultipleServers, getVideoChannelVideos, uploadVideo, wait } from '../../utils' +import { User, Video } from '../../../../shared/index' +import { doubleFollow, flushAndRunMultipleServers, getVideoChannelVideos, updateVideo, uploadVideo, wait } from '../../utils' import { addVideoChannel, deleteVideoChannel, @@ -25,8 +25,11 @@ describe('Test video channels', function () { let servers: ServerInfo[] let userInfo: User let accountUUID: string - let videoChannelId: number - let videoChannelUUID: string + let firstVideoChannelId: number + let firstVideoChannelUUID: string + let secondVideoChannelId: number + let secondVideoChannelUUID: string + let videoUUID: string before(async function () { this.timeout(30000) @@ -42,6 +45,9 @@ describe('Test video channels', function () { const res = await getMyUserInformation(servers[0].url, servers[0].accessToken) const user: User = res.body accountUUID = user.account.uuid + + firstVideoChannelId = user.videoChannels[0].id + firstVideoChannelUUID = user.videoChannels[0].uuid } await wait(5000) @@ -58,17 +64,22 @@ describe('Test video channels', function () { it('Should create another video channel', async function () { this.timeout(10000) - const videoChannel = { - displayName: 'second video channel', - description: 'super video channel description', - support: 'super video channel support text' + { + const videoChannel = { + displayName: 'second video channel', + description: 'super video channel description', + support: 'super video channel support text' + } + const res = await addVideoChannel(servers[ 0 ].url, servers[ 0 ].accessToken, videoChannel) + secondVideoChannelId = res.body.videoChannel.id + secondVideoChannelUUID = res.body.videoChannel.uuid } - const res = await addVideoChannel(servers[0].url, servers[0].accessToken, videoChannel) - videoChannelId = res.body.videoChannel.id - videoChannelUUID = res.body.videoChannel.uuid // The channel is 1 is propagated to servers 2 - await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'my video name', channelId: videoChannelId }) + { + const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'my video name', channelId: secondVideoChannelId }) + videoUUID = res.body.video.uuid + } await wait(3000) }) @@ -130,7 +141,7 @@ describe('Test video channels', function () { support: 'video channel support text updated' } - await updateVideoChannel(servers[0].url, servers[0].accessToken, videoChannelId, videoChannelAttributes) + await updateVideoChannel(servers[0].url, servers[0].accessToken, secondVideoChannelId, videoChannelAttributes) await wait(3000) }) @@ -149,7 +160,7 @@ describe('Test video channels', function () { }) it('Should get video channel', async function () { - const res = await getVideoChannel(servers[0].url, videoChannelId) + const res = await getVideoChannel(servers[0].url, secondVideoChannelId) const videoChannel = res.body expect(videoChannel.displayName).to.equal('video channel updated') @@ -157,20 +168,45 @@ describe('Test video channels', function () { expect(videoChannel.support).to.equal('video channel support text updated') }) - it('Should list the video channel videos', async function () { + it('Should list the second video channel videos', async function () { this.timeout(10000) for (const server of servers) { - const res = await getVideoChannelVideos(server.url, server.accessToken, videoChannelUUID, 0, 5) - expect(res.body.total).to.equal(1) - expect(res.body.data).to.be.an('array') - expect(res.body.data).to.have.lengthOf(1) - expect(res.body.data[0].name).to.equal('my video name') + const res1 = await getVideoChannelVideos(server.url, server.accessToken, secondVideoChannelUUID, 0, 5) + expect(res1.body.total).to.equal(1) + expect(res1.body.data).to.be.an('array') + expect(res1.body.data).to.have.lengthOf(1) + expect(res1.body.data[0].name).to.equal('my video name') + } + }) + + it('Should change the video channel of a video', async function () { + this.timeout(10000) + + await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { channelId: firstVideoChannelId }) + + await wait(5000) + }) + + it('Should list the first video channel videos', async function () { + this.timeout(10000) + + for (const server of servers) { + const res1 = await getVideoChannelVideos(server.url, server.accessToken, secondVideoChannelUUID, 0, 5) + expect(res1.body.total).to.equal(0) + + const res2 = await getVideoChannelVideos(server.url, server.accessToken, firstVideoChannelUUID, 0, 5) + expect(res2.body.total).to.equal(1) + + const videos: Video[] = res2.body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('my video name') } }) it('Should delete video channel', async function () { - await deleteVideoChannel(servers[0].url, servers[0].accessToken, videoChannelId) + await deleteVideoChannel(servers[0].url, servers[0].accessToken, secondVideoChannelId) }) it('Should have video channel deleted', async function () { diff --git a/server/tests/utils/videos/videos.ts b/server/tests/utils/videos/videos.ts index 870dfd21f..07c4ffc77 100644 --- a/server/tests/utils/videos/videos.ts +++ b/server/tests/utils/videos/videos.ts @@ -15,7 +15,7 @@ import { ServerInfo, testImage } from '../' -import { VideoPrivacy } from '../../../../shared/models/videos' +import { VideoDetails, VideoPrivacy } from '../../../../shared/models/videos' import { readdirPromise } from '../../../helpers/core-utils' import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers' import { dateIsValid, webtorrentAdd } from '../index' @@ -385,6 +385,7 @@ function updateVideo (url: string, accessToken: string, id: number | string, att if (attributes.description) body['description'] = attributes.description if (attributes.tags) body['tags'] = attributes.tags if (attributes.privacy) body['privacy'] = attributes.privacy + if (attributes.channelId) body['channelId'] = attributes.channelId // Upload request if (attributes.thumbnailfile || attributes.previewfile) { @@ -489,6 +490,8 @@ async function completeVideoCheck ( expect(video.account.uuid).to.be.a('string') expect(video.account.host).to.equal(attributes.account.host) expect(video.account.name).to.equal(attributes.account.name) + expect(video.channel.displayName).to.equal(attributes.channel.name) + expect(video.channel.name).to.have.lengthOf(36) expect(video.likes).to.equal(attributes.likes) expect(video.dislikes).to.equal(attributes.dislikes) expect(video.isLocal).to.equal(attributes.isLocal) @@ -498,19 +501,19 @@ async function completeVideoCheck ( expect(dateIsValid(video.updatedAt)).to.be.true const res = await getVideo(url, video.uuid) - const videoDetails = res.body + const videoDetails: VideoDetails = res.body expect(videoDetails.files).to.have.lengthOf(attributes.files.length) expect(videoDetails.tags).to.deep.equal(attributes.tags) expect(videoDetails.account.name).to.equal(attributes.account.name) expect(videoDetails.account.host).to.equal(attributes.account.host) - expect(videoDetails.commentsEnabled).to.equal(attributes.commentsEnabled) - expect(videoDetails.channel.displayName).to.equal(attributes.channel.name) expect(videoDetails.channel.name).to.have.lengthOf(36) + expect(videoDetails.channel.host).to.equal(attributes.account.host) expect(videoDetails.channel.isLocal).to.equal(attributes.channel.isLocal) - expect(dateIsValid(videoDetails.channel.createdAt)).to.be.true - expect(dateIsValid(videoDetails.channel.updatedAt)).to.be.true + expect(dateIsValid(videoDetails.channel.createdAt.toString())).to.be.true + expect(dateIsValid(videoDetails.channel.updatedAt.toString())).to.be.true + expect(videoDetails.commentsEnabled).to.equal(attributes.commentsEnabled) for (const attributeFile of attributes.files) { const file = videoDetails.files.find(f => f.resolution.id === attributeFile.resolution) diff --git a/shared/models/activitypub/activity.ts b/shared/models/activitypub/activity.ts index f555f0118..46e883e5f 100644 --- a/shared/models/activitypub/activity.ts +++ b/shared/models/activitypub/activity.ts @@ -64,7 +64,7 @@ export interface ActivityAnnounce extends BaseActivity { export interface ActivityUndo extends BaseActivity { type: 'Undo', - object: ActivityFollow | ActivityLike | ActivityCreate + object: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce } export interface ActivityLike extends BaseActivity { diff --git a/shared/models/actors/actor.model.ts b/shared/models/actors/actor.model.ts index f91616519..6b3b1b47c 100644 --- a/shared/models/actors/actor.model.ts +++ b/shared/models/actors/actor.model.ts @@ -8,7 +8,7 @@ export interface Actor { host: string followingCount: number followersCount: number - createdAt: Date - updatedAt: Date + createdAt: Date | string + updatedAt: Date | string avatar: Avatar } diff --git a/shared/models/videos/video-update.model.ts b/shared/models/videos/video-update.model.ts index 3a205bb83..c368d8464 100644 --- a/shared/models/videos/video-update.model.ts +++ b/shared/models/videos/video-update.model.ts @@ -11,6 +11,7 @@ export interface VideoUpdate { tags?: string[] commentsEnabled?: boolean nsfw?: boolean + channelId?: number thumbnailfile?: Blob previewfile?: Blob } diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index ba1881da3..eb40e82de 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts @@ -48,6 +48,16 @@ export interface Video { host: string avatar: Avatar } + + channel: { + id: number + uuid: string + name: string + displayName: string + url: string + host: string + avatar: Avatar + } } export interface VideoDetails extends Video {