From fd45e8f43c2638478599ca75632518054461da85 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 31 Oct 2017 11:52:52 +0100 Subject: Add video privacy setting --- server/controllers/api/remote/videos.ts | 4 +- server/controllers/api/users.ts | 21 +++++- server/controllers/api/videos/index.ts | 28 ++++++-- server/helpers/custom-validators/videos.ts | 12 ++++ server/initializers/constants.ts | 10 ++- .../initializers/migrations/0095-videos-privacy.ts | 35 ++++++++++ server/middlewares/validators/videos.ts | 19 ++++-- server/models/video/video-interface.ts | 3 + server/models/video/video.ts | 78 +++++++++++++++++----- 9 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 server/initializers/migrations/0095-videos-privacy.ts (limited to 'server') diff --git a/server/controllers/api/remote/videos.ts b/server/controllers/api/remote/videos.ts index 3ecc62ada..cba47f0a1 100644 --- a/server/controllers/api/remote/videos.ts +++ b/server/controllers/api/remote/videos.ts @@ -267,7 +267,8 @@ async function addRemoteVideo (videoToCreateData: RemoteVideoCreateData, fromPod views: videoToCreateData.views, likes: videoToCreateData.likes, dislikes: videoToCreateData.dislikes, - remote: true + remote: true, + privacy: videoToCreateData.privacy } const video = db.Video.build(videoData) @@ -334,6 +335,7 @@ async function updateRemoteVideo (videoAttributesToUpdate: RemoteVideoUpdateData videoInstance.set('views', videoAttributesToUpdate.views) videoInstance.set('likes', videoAttributesToUpdate.likes) videoInstance.set('dislikes', videoAttributesToUpdate.dislikes) + videoInstance.set('privacy', videoAttributesToUpdate.privacy) await videoInstance.save(sequelizeOptions) diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts index fdc9b0c87..dcd407fdf 100644 --- a/server/controllers/api/users.ts +++ b/server/controllers/api/users.ts @@ -30,6 +30,8 @@ import { } from '../../../shared' import { createUserAuthorAndChannel } from '../../lib' import { UserInstance } from '../../models' +import { videosSortValidator } from '../../middlewares/validators/sort' +import { setVideosSort } from '../../middlewares/sort' const usersRouter = express.Router() @@ -38,6 +40,15 @@ usersRouter.get('/me', asyncMiddleware(getUserInformation) ) +usersRouter.get('/me/videos', + authenticate, + paginationValidator, + videosSortValidator, + setVideosSort, + setPagination, + asyncMiddleware(getUserVideos) +) + usersRouter.get('/me/videos/:videoId/rating', authenticate, usersVideoRatingValidator, @@ -101,6 +112,13 @@ export { // --------------------------------------------------------------------------- +async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) { + const user = res.locals.oauth.token.User + const resultList = await db.Video.listUserVideosForApi(user.id ,req.query.start, req.query.count, req.query.sort) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + async function createUserRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) { const options = { arguments: [ req, res ], @@ -146,13 +164,14 @@ async function registerUser (req: express.Request, res: express.Response, next: } async function getUserInformation (req: express.Request, res: express.Response, next: express.NextFunction) { + // We did not load channels in res.locals.user const user = await db.User.loadByUsernameAndPopulateChannels(res.locals.oauth.token.user.username) return res.json(user.toFormattedJSON()) } function getUser (req: express.Request, res: express.Response, next: express.NextFunction) { - return res.json(res.locals.user.toFormattedJSON()) + return res.json(res.locals.oauth.token.User.toFormattedJSON()) } async function getUserVideoRating (req: express.Request, res: express.Response, next: express.NextFunction) { diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 49f0e4630..4dd09917b 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -9,7 +9,8 @@ import { REQUEST_VIDEO_EVENT_TYPES, VIDEO_CATEGORIES, VIDEO_LICENCES, - VIDEO_LANGUAGES + VIDEO_LANGUAGES, + VIDEO_PRIVACIES } from '../../../initializers' import { addEventToRemoteVideo, @@ -43,7 +44,7 @@ import { resetSequelizeInstance } from '../../../helpers' import { VideoInstance } from '../../../models' -import { VideoCreate, VideoUpdate } from '../../../../shared' +import { VideoCreate, VideoUpdate, VideoPrivacy } from '../../../../shared' import { abuseVideoRouter } from './abuse' import { blacklistRouter } from './blacklist' @@ -84,6 +85,7 @@ videosRouter.use('/', videoChannelRouter) videosRouter.get('/categories', listVideoCategories) videosRouter.get('/licences', listVideoLicences) videosRouter.get('/languages', listVideoLanguages) +videosRouter.get('/privacies', listVideoPrivacies) videosRouter.get('/', paginationValidator, @@ -149,6 +151,10 @@ function listVideoLanguages (req: express.Request, res: express.Response) { res.json(VIDEO_LANGUAGES) } +function listVideoPrivacies (req: express.Request, res: express.Response) { + res.json(VIDEO_PRIVACIES) +} + // Wrapper to video add that retry the function if there is a database error // We need this because we run the transaction in SERIALIZABLE isolation that can fail async function addVideoRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) { @@ -179,6 +185,7 @@ async function addVideo (req: express.Request, res: express.Response, videoPhysi language: videoInfo.language, nsfw: videoInfo.nsfw, description: videoInfo.description, + privacy: videoInfo.privacy, duration: videoPhysicalFile['duration'], // duration was added by a previous middleware channelId: res.locals.videoChannel.id } @@ -240,6 +247,8 @@ async function addVideo (req: express.Request, res: express.Response, videoPhysi // Let transcoding job send the video to friends because the video file extension might change if (CONFIG.TRANSCODING.ENABLED === true) return undefined + // Don't send video to remote pods, it is private + if (video.privacy === VideoPrivacy.PRIVATE) return undefined const remoteVideo = await video.toAddRemoteJSON() // Now we'll add the video's meta data to our friends @@ -264,6 +273,7 @@ async function updateVideo (req: express.Request, res: express.Response) { const videoInstance = res.locals.video const videoFieldsSave = videoInstance.toJSON() const videoInfoToUpdate: VideoUpdate = req.body + const wasPrivateVideo = videoInstance.privacy === VideoPrivacy.PRIVATE try { await db.sequelize.transaction(async t => { @@ -276,6 +286,7 @@ async function updateVideo (req: express.Request, res: express.Response) { if (videoInfoToUpdate.licence !== undefined) videoInstance.set('licence', videoInfoToUpdate.licence) if (videoInfoToUpdate.language !== undefined) videoInstance.set('language', videoInfoToUpdate.language) if (videoInfoToUpdate.nsfw !== undefined) videoInstance.set('nsfw', videoInfoToUpdate.nsfw) + if (videoInfoToUpdate.privacy !== undefined) videoInstance.set('privacy', videoInfoToUpdate.privacy) if (videoInfoToUpdate.description !== undefined) videoInstance.set('description', videoInfoToUpdate.description) await videoInstance.save(sequelizeOptions) @@ -287,10 +298,17 @@ async function updateVideo (req: express.Request, res: express.Response) { videoInstance.Tags = tagInstances } - const json = videoInstance.toUpdateRemoteJSON() - // Now we'll update the video's meta data to our friends - return updateVideoToFriends(json, t) + if (wasPrivateVideo === false) { + const json = videoInstance.toUpdateRemoteJSON() + return updateVideoToFriends(json, t) + } + + // Video is not private anymore, send a create action to remote pods + if (wasPrivateVideo === true && videoInstance.privacy !== VideoPrivacy.PRIVATE) { + const remoteVideo = await videoInstance.toAddRemoteJSON() + return addVideoToFriends(remoteVideo, t) + } }) logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid) diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 5b9102275..f3fdcaf2d 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -11,6 +11,7 @@ import { VIDEO_LICENCES, VIDEO_LANGUAGES, VIDEO_RATE_TYPES, + VIDEO_PRIVACIES, database as db } from '../../initializers' import { isUserUsernameValid } from './users' @@ -36,6 +37,15 @@ function isVideoLicenceValid (value: number) { return VIDEO_LICENCES[value] !== undefined } +function isVideoPrivacyValid (value: string) { + return VIDEO_PRIVACIES[value] !== undefined +} + +// Maybe we don't know the remote privacy setting, but that doesn't matter +function isRemoteVideoPrivacyValid (value: string) { + return validator.isInt('' + value) +} + // Maybe we don't know the remote licence, but that doesn't matter function isRemoteVideoLicenceValid (value: string) { return validator.isInt('' + value) @@ -195,6 +205,8 @@ export { isVideoDislikesValid, isVideoEventCountValid, isVideoFileSizeValid, + isVideoPrivacyValid, + isRemoteVideoPrivacyValid, isVideoFileResolutionValid, checkVideoExists, isRemoteVideoCategoryValid, diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index adccb9f41..d349abaf0 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -12,10 +12,11 @@ import { RemoteVideoRequestType, JobState } from '../../shared/models' +import { VideoPrivacy } from '../../shared/models/videos/video-privacy.enum' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 90 +const LAST_MIGRATION_VERSION = 95 // --------------------------------------------------------------------------- @@ -196,6 +197,12 @@ const VIDEO_LANGUAGES = { 14: 'Italian' } +const VIDEO_PRIVACIES = { + [VideoPrivacy.PUBLIC]: 'Public', + [VideoPrivacy.UNLISTED]: 'Unlisted', + [VideoPrivacy.PRIVATE]: 'Private' +} + // --------------------------------------------------------------------------- // Score a pod has when we create it as a friend @@ -394,6 +401,7 @@ export { THUMBNAILS_SIZE, VIDEO_CATEGORIES, VIDEO_LANGUAGES, + VIDEO_PRIVACIES, VIDEO_LICENCES, VIDEO_RATE_TYPES } diff --git a/server/initializers/migrations/0095-videos-privacy.ts b/server/initializers/migrations/0095-videos-privacy.ts new file mode 100644 index 000000000..4c2bf91d0 --- /dev/null +++ b/server/initializers/migrations/0095-videos-privacy.ts @@ -0,0 +1,35 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction, + queryInterface: Sequelize.QueryInterface, + sequelize: Sequelize.Sequelize, + db: any +}): Promise { + const q = utils.queryInterface + + const data = { + type: Sequelize.INTEGER, + defaultValue: null, + allowNull: true + } + await q.addColumn('Videos', 'privacy', data) + + const query = 'UPDATE "Videos" SET "privacy" = 1' + const options = { + type: Sequelize.QueryTypes.BULKUPDATE + } + await utils.sequelize.query(query, options) + + data.allowNull = false + await q.changeColumn('Videos', 'privacy', data) +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts index 0c07404c5..e197d4606 100644 --- a/server/middlewares/validators/videos.ts +++ b/server/middlewares/validators/videos.ts @@ -20,9 +20,10 @@ import { isVideoRatingTypeValid, getDurationFromVideoFile, checkVideoExists, - isIdValid + isIdValid, + isVideoPrivacyValid } from '../../helpers' -import { UserRight } from '../../../shared' +import { UserRight, VideoPrivacy } from '../../../shared' const videosAddValidator = [ body('videofile').custom((value, { req }) => isVideoFile(req.files)).withMessage( @@ -36,6 +37,7 @@ const videosAddValidator = [ body('nsfw').custom(isVideoNSFWValid).withMessage('Should have a valid NSFW attribute'), body('description').custom(isVideoDescriptionValid).withMessage('Should have a valid description'), body('channelId').custom(isIdValid).withMessage('Should have correct video channel id'), + body('privacy').custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'), body('tags').optional().custom(isVideoTagsValid).withMessage('Should have correct tags'), (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -110,6 +112,7 @@ const videosUpdateValidator = [ body('licence').optional().custom(isVideoLicenceValid).withMessage('Should have a valid licence'), body('language').optional().custom(isVideoLanguageValid).withMessage('Should have a valid language'), body('nsfw').optional().custom(isVideoNSFWValid).withMessage('Should have a valid NSFW attribute'), + body('privacy').custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'), body('description').optional().custom(isVideoDescriptionValid).withMessage('Should have a valid description'), body('tags').optional().custom(isVideoTagsValid).withMessage('Should have correct tags'), @@ -118,19 +121,27 @@ const videosUpdateValidator = [ checkErrors(req, res, () => { checkVideoExists(req.params.id, res, () => { + const video = res.locals.video + // We need to make additional checks - if (res.locals.video.isOwned() === false) { + if (video.isOwned() === false) { return res.status(403) .json({ error: 'Cannot update video of another pod' }) .end() } - if (res.locals.video.VideoChannel.Author.userId !== res.locals.oauth.token.User.id) { + if (video.VideoChannel.Author.userId !== res.locals.oauth.token.User.id) { return res.status(403) .json({ error: 'Cannot update video of another user' }) .end() } + if (video.privacy !== VideoPrivacy.PRIVATE && req.body.privacy === VideoPrivacy.PRIVATE) { + return res.status(409) + .json({ error: 'Cannot set "private" a video that was not private anymore.' }) + .end() + } + next() }) }) diff --git a/server/models/video/video-interface.ts b/server/models/video/video-interface.ts index 587652f45..cfe65f9aa 100644 --- a/server/models/video/video-interface.ts +++ b/server/models/video/video-interface.ts @@ -49,6 +49,7 @@ export namespace VideoMethods { export type ListOwnedByAuthor = (author: string) => Promise export type ListForApi = (start: number, count: number, sort: string) => Promise< ResultList > + export type ListUserVideosForApi = (userId: number, start: number, count: number, sort: string) => Promise< ResultList > export type SearchAndPopulateAuthorAndPodAndTags = ( value: string, field: string, @@ -75,6 +76,7 @@ export interface VideoClass { generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData list: VideoMethods.List listForApi: VideoMethods.ListForApi + listUserVideosForApi: VideoMethods.ListUserVideosForApi listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags listOwnedByAuthor: VideoMethods.ListOwnedByAuthor load: VideoMethods.Load @@ -97,6 +99,7 @@ export interface VideoAttributes { nsfw: boolean description: string duration: number + privacy: number views?: number likes?: number dislikes?: number diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 1877c506a..2c1bd6b6e 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -18,6 +18,7 @@ import { isVideoNSFWValid, isVideoDescriptionValid, isVideoDurationValid, + isVideoPrivacyValid, readFileBufferPromise, unlinkPromise, renamePromise, @@ -38,10 +39,11 @@ import { THUMBNAILS_SIZE, PREVIEWS_SIZE, CONSTRAINTS_FIELDS, - API_VERSION + API_VERSION, + VIDEO_PRIVACIES } from '../../initializers' import { removeVideoToFriends } from '../../lib' -import { VideoResolution } from '../../../shared' +import { VideoResolution, VideoPrivacy } from '../../../shared' import { VideoFileInstance, VideoFileModel } from './video-file-interface' import { addMethodsToModel, getSort } from '../utils' @@ -79,6 +81,7 @@ let getTruncatedDescription: VideoMethods.GetTruncatedDescription let generateThumbnailFromData: VideoMethods.GenerateThumbnailFromData let list: VideoMethods.List let listForApi: VideoMethods.ListForApi +let listUserVideosForApi: VideoMethods.ListUserVideosForApi let loadByHostAndUUID: VideoMethods.LoadByHostAndUUID let listOwnedAndPopulateAuthorAndTags: VideoMethods.ListOwnedAndPopulateAuthorAndTags let listOwnedByAuthor: VideoMethods.ListOwnedByAuthor @@ -146,6 +149,16 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da } } }, + privacy: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + privacyValid: value => { + const res = isVideoPrivacyValid(value) + if (res === false) throw new Error('Video privacy is not valid.') + } + } + }, nsfw: { type: DataTypes.BOOLEAN, allowNull: false, @@ -245,6 +258,7 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da generateThumbnailFromData, list, listForApi, + listUserVideosForApi, listOwnedAndPopulateAuthorAndTags, listOwnedByAuthor, load, @@ -501,7 +515,13 @@ toFormattedJSON = function (this: VideoInstance) { toFormattedDetailsJSON = function (this: VideoInstance) { const formattedJson = this.toFormattedJSON() + // Maybe our pod is not up to date and there are new privacy settings since our version + let privacyLabel = VIDEO_PRIVACIES[this.privacy] + if (!privacyLabel) privacyLabel = 'Unknown' + const detailsJson = { + privacyLabel, + privacy: this.privacy, descriptionPath: this.getDescriptionPath(), channel: this.VideoChannel.toFormattedJSON(), files: [] @@ -555,6 +575,7 @@ toAddRemoteJSON = function (this: VideoInstance) { views: this.views, likes: this.likes, dislikes: this.dislikes, + privacy: this.privacy, files: [] } @@ -587,6 +608,7 @@ toUpdateRemoteJSON = function (this: VideoInstance) { views: this.views, likes: this.likes, dislikes: this.dislikes, + privacy: this.privacy, files: [] } @@ -746,8 +768,39 @@ list = function () { return Video.findAll(query) } +listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) { + const query = { + distinct: true, + offset: start, + limit: count, + order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ], + include: [ + { + model: Video['sequelize'].models.VideoChannel, + required: true, + include: [ + { + model: Video['sequelize'].models.Author, + where: { + userId + }, + required: true + } + ] + }, + Video['sequelize'].models.Tag + ] + } + + return Video.findAndCountAll(query).then(({ rows, count }) => { + return { + data: rows, + total: count + } + }) +} + listForApi = function (start: number, count: number, sort: string) { - // Exclude blacklisted videos from the list const query = { distinct: true, offset: start, @@ -768,8 +821,7 @@ listForApi = function (start: number, count: number, sort: string) { } ] }, - Video['sequelize'].models.Tag, - Video['sequelize'].models.VideoFile + Video['sequelize'].models.Tag ], where: createBaseVideosWhere() } @@ -969,10 +1021,6 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s model: Video['sequelize'].models.Tag } - const videoFileInclude: Sequelize.IncludeOptions = { - model: Video['sequelize'].models.VideoFile - } - const query: Sequelize.FindOptions = { distinct: true, where: createBaseVideosWhere(), @@ -981,12 +1029,7 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ] } - // Make an exact search with the magnet - if (field === 'magnetUri') { - videoFileInclude.where = { - infoHash: magnetUtil.decode(value).infoHash - } - } else if (field === 'tags') { + if (field === 'tags') { const escapedValue = Video['sequelize'].escape('%' + value + '%') query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal( `(SELECT "VideoTags"."videoId" @@ -1016,7 +1059,7 @@ searchAndPopulateAuthorAndPodAndTags = function (value: string, field: string, s } query.include = [ - videoChannelInclude, tagInclude, videoFileInclude + videoChannelInclude, tagInclude ] return Video.findAndCountAll(query).then(({ rows, count }) => { @@ -1035,7 +1078,8 @@ function createBaseVideosWhere () { [Sequelize.Op.notIn]: Video['sequelize'].literal( '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")' ) - } + }, + privacy: VideoPrivacy.PUBLIC } } -- cgit v1.2.3