Add /accounts/:username/ratings endpoint (#1756)
authorYohan Boniface <yohanboniface@free.fr>
Tue, 9 Apr 2019 09:02:02 +0000 (11:02 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Tue, 9 Apr 2019 09:02:02 +0000 (11:02 +0200)
* Add /users/me/videos/ratings endpoint

* Move ratings endpoint from users to accounts

* /accounts/:name/ratings: add support for rating= and sort=

* Restrict ratings list to owner

* Wording and better way to ensure current account

13 files changed:
server/controllers/api/accounts.ts
server/helpers/custom-validators/video-rates.ts [new file with mode: 0644]
server/initializers/constants.ts
server/middlewares/validators/sort.ts
server/middlewares/validators/users.ts
server/middlewares/validators/videos/video-rates.ts
server/models/account/account-video-rate.ts
server/tests/api/users/users.ts
shared/models/videos/index.ts
shared/models/videos/rate/account-video-rate.model.ts [new file with mode: 0644]
shared/utils/index.ts
shared/utils/users/accounts.ts
support/doc/api/openapi.yaml

index adbf69781dd0e9681c8eabf07d314a296f2b65e4..aa01ea1ebb0b966bd4b45bd210843928eca1bbe6 100644 (file)
@@ -1,16 +1,25 @@
 import * as express from 'express'
 import { getFormattedObjects, getServerActor } from '../../helpers/utils'
 import {
+  authenticate,
   asyncMiddleware,
   commonVideosFiltersValidator,
+  videoRatingValidator,
   optionalAuthenticate,
   paginationValidator,
   setDefaultPagination,
   setDefaultSort,
-  videoPlaylistsSortValidator
+  videoPlaylistsSortValidator,
+  videoRatesSortValidator
 } from '../../middlewares'
-import { accountNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators'
+import {
+  accountNameWithHostGetValidator,
+  accountsSortValidator,
+  videosSortValidator,
+  ensureAuthUserOwnsAccountValidator
+} from '../../middlewares/validators'
 import { AccountModel } from '../../models/account/account'
+import { AccountVideoRateModel } from '../../models/account/account-video-rate'
 import { VideoModel } from '../../models/video/video'
 import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
 import { VideoChannelModel } from '../../models/video/video-channel'
@@ -61,6 +70,18 @@ accountsRouter.get('/:accountName/video-playlists',
   asyncMiddleware(listAccountPlaylists)
 )
 
+accountsRouter.get('/:accountName/ratings',
+  authenticate,
+  asyncMiddleware(accountNameWithHostGetValidator),
+  ensureAuthUserOwnsAccountValidator,
+  paginationValidator,
+  videoRatesSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  videoRatingValidator,
+  asyncMiddleware(listAccountRatings)
+)
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -138,3 +159,16 @@ async function listAccountVideos (req: express.Request, res: express.Response) {
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }
+
+async function listAccountRatings (req: express.Request, res: express.Response) {
+  const account = res.locals.account
+
+  const resultList = await AccountVideoRateModel.listByAccountForApi({
+    accountId: account.id,
+    start: req.query.start,
+    count: req.query.count,
+    sort: req.query.sort,
+    type: req.query.rating
+  })
+  return res.json(getFormattedObjects(resultList.rows, resultList.count))
+}
diff --git a/server/helpers/custom-validators/video-rates.ts b/server/helpers/custom-validators/video-rates.ts
new file mode 100644 (file)
index 0000000..f2b6f7c
--- /dev/null
@@ -0,0 +1,5 @@
+function isRatingValid (value: any) {
+  return value === 'like' || value === 'dislike'
+}
+
+export { isRatingValid }
index 78dd7cb9da7255f58f90e91a63a49d9b1ae6fd84..097199f8405a82ca736cdb9c79308dcd0dabea4b 100644 (file)
@@ -42,6 +42,7 @@ const SORTABLE_COLUMNS = {
   VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ],
   VIDEO_IMPORTS: [ 'createdAt' ],
   VIDEO_COMMENT_THREADS: [ 'createdAt' ],
+  VIDEO_RATES: [ 'createdAt' ],
   BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
   FOLLOWERS: [ 'createdAt' ],
   FOLLOWING: [ 'createdAt' ],
index ea59fbf73693681e73c7f0e5ee1d1b3a6f6ca8a7..44295c3251868f597fc32fb14bf1f84d9508ac5e 100644 (file)
@@ -11,6 +11,7 @@ const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VI
 const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
 const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
 const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
+const SORTABLE_VIDEO_RATES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_RATES)
 const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS)
 const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS)
 const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWERS)
@@ -30,6 +31,7 @@ const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
 const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
 const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS)
 const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
+const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS)
 const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
 const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
 const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS)
@@ -55,6 +57,7 @@ export {
   followingSortValidator,
   jobsSortValidator,
   videoCommentThreadsSortValidator,
+  videoRatesSortValidator,
   userSubscriptionsSortValidator,
   videoChannelsSearchSortValidator,
   accountsBlocklistSortValidator,
index 4be446732d1959adc44ddb48c442dac6f13f808f..35f41c450f4720cdcbd9f3b2d11a8a61276e8d55 100644 (file)
@@ -22,6 +22,7 @@ import { logger } from '../../helpers/logger'
 import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup'
 import { Redis } from '../../lib/redis'
 import { UserModel } from '../../models/account/user'
+import { AccountModel } from '../../models/account/account'
 import { areValidationErrors } from './utils'
 import { ActorModel } from '../../models/activitypub/actor'
 
@@ -317,6 +318,20 @@ const userAutocompleteValidator = [
   param('search').isString().not().isEmpty().withMessage('Should have a search parameter')
 ]
 
+const ensureAuthUserOwnsAccountValidator = [
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    const user = res.locals.oauth.token.User
+
+    if (res.locals.account.id !== user.Account.id) {
+      return res.status(403)
+                .send({ error: 'Only owner can access ratings list.' })
+                .end()
+    }
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -335,7 +350,8 @@ export {
   usersResetPasswordValidator,
   usersAskSendVerifyEmailValidator,
   usersVerifyEmailValidator,
-  userAutocompleteValidator
+  userAutocompleteValidator,
+  ensureAuthUserOwnsAccountValidator
 }
 
 // ---------------------------------------------------------------------------
index 28038591243b807e8585559c4cc543527e9ba123..e79d80e975cce7de0923f7d44e60f8d3924b87cb 100644 (file)
@@ -1,7 +1,8 @@
 import * as express from 'express'
 import 'express-validator'
-import { body, param } from 'express-validator/check'
+import { body, param, query } from 'express-validator/check'
 import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
+import { isRatingValid } from '../../../helpers/custom-validators/video-rates'
 import { doesVideoExist, isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
 import { logger } from '../../../helpers/logger'
 import { areValidationErrors } from '../utils'
@@ -47,9 +48,22 @@ const getAccountVideoRateValidator = function (rateType: VideoRateType) {
   ]
 }
 
+const videoRatingValidator = [
+  query('rating').optional().custom(isRatingValid).withMessage('Value must be one of "like" or "dislike"'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking rating parameter', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
   videoUpdateRateValidator,
-  getAccountVideoRateValidator
+  getAccountVideoRateValidator,
+  videoRatingValidator
 }
index e5d39582bdf7698f9b36e3adf1a5e7764b054be5..f462df4b33d78e6f498f01111862fd5acac10349 100644 (file)
@@ -7,8 +7,10 @@ import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers'
 import { VideoModel } from '../video/video'
 import { AccountModel } from './account'
 import { ActorModel } from '../activitypub/actor'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid, getSort } from '../utils'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
+import { AccountVideoRate } from '../../../shared'
+import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from '../video/video-channel'
 
 /*
   Account rates per video.
@@ -88,6 +90,38 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
     return AccountVideoRateModel.findOne(options)
   }
 
+  static listByAccountForApi (options: {
+    start: number,
+    count: number,
+    sort: string,
+    type?: string,
+    accountId: number
+  }) {
+    const query: IFindOptions<AccountVideoRateModel> = {
+      offset: options.start,
+      limit: options.count,
+      order: getSort(options.sort),
+      where: {
+        accountId: options.accountId
+      },
+      include: [
+        {
+          model: VideoModel,
+          required: true,
+          include: [
+            {
+              model: VideoChannelModel.scope({ method: [VideoChannelScopeNames.SUMMARY, true] }),
+              required: true
+            }
+          ]
+        }
+      ]
+    }
+    if (options.type) query.where['type'] = options.type
+
+    return AccountVideoRateModel.findAndCountAll(query)
+  }
+
   static loadLocalAndPopulateVideo (rateType: VideoRateType, accountName: string, videoId: number, transaction?: Transaction) {
     const options: IFindOptions<AccountVideoRateModel> = {
       where: {
@@ -185,4 +219,11 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
       else if (type === 'dislike') await VideoModel.increment({ dislikes: -deleted }, options)
     })
   }
+
+  toFormattedJSON (): AccountVideoRate {
+    return {
+      video: this.Video.toFormattedJSON(),
+      rating: this.type
+    }
+  }
 }
index c4465d541142874849b495249dab1c07933571ba..bc069a7bea5ab1c42e47fa2bce33b0da05317f22 100644 (file)
@@ -8,6 +8,7 @@ import {
   createUser,
   deleteMe,
   flushTests,
+  getAccountRatings,
   getBlacklistedVideosList,
   getMyUserInformation,
   getMyUserVideoQuotaUsed,
@@ -32,7 +33,7 @@ import {
   updateUser,
   uploadVideo,
   userLogin
-} from '../../../../shared/utils/index'
+} from '../../../../shared/utils'
 import { follow } from '../../../../shared/utils/server/follows'
 import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
 import { getMyVideos } from '../../../../shared/utils/videos/videos'
@@ -137,6 +138,35 @@ describe('Test users', function () {
     expect(rating.rating).to.equal('like')
   })
 
+  it('Should retrieve ratings list', async function () {
+    await rateVideo(server.url, accessToken, videoId, 'like')
+    const res = await getAccountRatings(server.url, server.user.username, server.accessToken, 200)
+    const ratings = res.body
+
+    expect(ratings.data[0].video.id).to.equal(videoId)
+    expect(ratings.data[0].rating).to.equal('like')
+  })
+
+  it('Should retrieve ratings list by rating type', async function () {
+    await rateVideo(server.url, accessToken, videoId, 'like')
+    let res = await getAccountRatings(server.url, server.user.username, server.accessToken, 200, { rating: 'like' })
+    let ratings = res.body
+    expect(ratings.data.length).to.equal(1)
+    res = await getAccountRatings(server.url, server.user.username, server.accessToken, 200, { rating: 'dislike' })
+    ratings = res.body
+    expect(ratings.data.length).to.equal(0)
+    await getAccountRatings(server.url, server.user.username, server.accessToken, 400, { rating: 'invalid' })
+  })
+
+  it('Should not access ratings list if not logged with correct user', async function () {
+    const user = { username: 'anuragh', password: 'passbyme' }
+    const resUser = await createUser(server.url, server.accessToken, user.username, user.password)
+    const userId = resUser.body.user.id
+    const userAccessToken = await userLogin(server, user)
+    await getAccountRatings(server.url, server.user.username, userAccessToken, 403)
+    await removeUser(server.url, userId, server.accessToken)
+  })
+
   it('Should not be able to remove the video with an incorrect token', async function () {
     await removeVideo(server.url, 'bad_token', videoId, 401)
   })
index 9cf861048525693c8fe2bb6b3eef95ad596f62ea..e3d78220e1621f1ca319dcd7e4e243f1cca43ee8 100644 (file)
@@ -1,5 +1,6 @@
 export * from './rate/user-video-rate-update.model'
 export * from './rate/user-video-rate.model'
+export * from './rate/account-video-rate.model'
 export * from './rate/user-video-rate.type'
 export * from './abuse/video-abuse-state.model'
 export * from './abuse/video-abuse-create.model'
diff --git a/shared/models/videos/rate/account-video-rate.model.ts b/shared/models/videos/rate/account-video-rate.model.ts
new file mode 100644 (file)
index 0000000..e789367
--- /dev/null
@@ -0,0 +1,7 @@
+import { UserVideoRateType } from './user-video-rate.type'
+import { Video } from '../video.model'
+
+export interface AccountVideoRate {
+  video: Video
+  rating: UserVideoRateType
+}
index c09565d95fa53b464fa0b66f63acf75dc27fdc1f..469546872ead5683ad13453615945793956afa96 100644 (file)
@@ -15,6 +15,7 @@ export * from './server/servers'
 export * from './videos/services'
 export * from './videos/video-playlists'
 export * from './users/users'
+export * from './users/accounts'
 export * from './videos/video-abuses'
 export * from './videos/video-blacklist'
 export * from './videos/video-channels'
index 388eb6973c79a7ca3c1c3d54161f9857ebe1e72a..54d66ac2a42d29f879679977d2a896817ce5c69a 100644 (file)
@@ -1,5 +1,6 @@
 /* tslint:disable:no-unused-expression */
 
+import * as request from 'supertest'
 import { expect } from 'chai'
 import { existsSync, readdir } from 'fs-extra'
 import { join } from 'path'
@@ -53,11 +54,24 @@ async function checkActorFilesWereRemoved (actorUUID: string, serverNumber: numb
   }
 }
 
+function getAccountRatings (url: string, accountName: string, accessToken: string, statusCodeExpected = 200, query = {}) {
+  const path = '/api/v1/accounts/' + accountName + '/ratings'
+
+  return request(url)
+          .get(path)
+          .query(query)
+          .set('Accept', 'application/json')
+          .set('Authorization', 'Bearer ' + accessToken)
+          .expect(statusCodeExpected)
+          .expect('Content-Type', /json/)
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   getAccount,
   expectAccountFollows,
   getAccountsList,
-  checkActorFilesWereRemoved
+  checkActorFilesWereRemoved,
+  getAccountRatings
 }
index 46c66a101781ebe3191339155ea720ae3a61d25c..833a98d3b7ca0d834cc9b410fd6b952b3d51d2f5 100644 (file)
@@ -1344,6 +1344,35 @@ paths:
                 type: array
                 items:
                   $ref: '#/components/schemas/VideoChannel'
+  '/accounts/{name}/ratings':
+    get:
+      summary: Get ratings of an account by its name
+      security:
+        - OAuth2: []
+      tags:
+        - User
+      parameters:
+        - $ref: '#/components/parameters/start'
+        - $ref: '#/components/parameters/count'
+        - $ref: '#/components/parameters/sort'
+        - name: rating
+          in: query
+          required: false
+          description: Optionaly filter which ratings to retrieve
+          schema:
+            type: string
+            enum:
+              - like
+              - dislike
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/VideoRating'
   '/videos/{id}/comment-threads':
     get:
       summary: Get the comment threads of a video by its id
@@ -2142,6 +2171,16 @@ components:
       required:
         - id
         - rating
+    VideoRating:
+      properties:
+        video:
+          $ref: '#/components/schemas/Video'
+        rating:
+          type: number
+          description: 'Rating of the video'
+      required:
+        - video
+        - rating
     RegisterUser:
       properties:
         username: