Add /accounts/:username/ratings endpoint (#1756)

* 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
This commit is contained in:
Yohan Boniface 2019-04-09 11:02:02 +02:00 committed by Chocobozzz
parent 8ce1ba6e3e
commit c100a6142e
13 changed files with 214 additions and 8 deletions

View File

@ -1,16 +1,25 @@
import * as express from 'express' import * as express from 'express'
import { getFormattedObjects, getServerActor } from '../../helpers/utils' import { getFormattedObjects, getServerActor } from '../../helpers/utils'
import { import {
authenticate,
asyncMiddleware, asyncMiddleware,
commonVideosFiltersValidator, commonVideosFiltersValidator,
videoRatingValidator,
optionalAuthenticate, optionalAuthenticate,
paginationValidator, paginationValidator,
setDefaultPagination, setDefaultPagination,
setDefaultSort, setDefaultSort,
videoPlaylistsSortValidator videoPlaylistsSortValidator,
videoRatesSortValidator
} from '../../middlewares' } from '../../middlewares'
import { accountNameWithHostGetValidator, accountsSortValidator, videosSortValidator } from '../../middlewares/validators' import {
accountNameWithHostGetValidator,
accountsSortValidator,
videosSortValidator,
ensureAuthUserOwnsAccountValidator
} from '../../middlewares/validators'
import { AccountModel } from '../../models/account/account' import { AccountModel } from '../../models/account/account'
import { AccountVideoRateModel } from '../../models/account/account-video-rate'
import { VideoModel } from '../../models/video/video' import { VideoModel } from '../../models/video/video'
import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
import { VideoChannelModel } from '../../models/video/video-channel' import { VideoChannelModel } from '../../models/video/video-channel'
@ -61,6 +70,18 @@ accountsRouter.get('/:accountName/video-playlists',
asyncMiddleware(listAccountPlaylists) asyncMiddleware(listAccountPlaylists)
) )
accountsRouter.get('/:accountName/ratings',
authenticate,
asyncMiddleware(accountNameWithHostGetValidator),
ensureAuthUserOwnsAccountValidator,
paginationValidator,
videoRatesSortValidator,
setDefaultSort,
setDefaultPagination,
videoRatingValidator,
asyncMiddleware(listAccountRatings)
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -138,3 +159,16 @@ async function listAccountVideos (req: express.Request, res: express.Response) {
return res.json(getFormattedObjects(resultList.data, resultList.total)) 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))
}

View File

@ -0,0 +1,5 @@
function isRatingValid (value: any) {
return value === 'like' || value === 'dislike'
}
export { isRatingValid }

View File

@ -42,6 +42,7 @@ const SORTABLE_COLUMNS = {
VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ], VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ],
VIDEO_IMPORTS: [ 'createdAt' ], VIDEO_IMPORTS: [ 'createdAt' ],
VIDEO_COMMENT_THREADS: [ 'createdAt' ], VIDEO_COMMENT_THREADS: [ 'createdAt' ],
VIDEO_RATES: [ 'createdAt' ],
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
FOLLOWERS: [ 'createdAt' ], FOLLOWERS: [ 'createdAt' ],
FOLLOWING: [ 'createdAt' ], FOLLOWING: [ 'createdAt' ],

View 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_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) 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_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_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS)
const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS) const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS)
const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWERS) 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 videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS) const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS)
const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS)
const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS) const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS) const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS) const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS)
@ -55,6 +57,7 @@ export {
followingSortValidator, followingSortValidator,
jobsSortValidator, jobsSortValidator,
videoCommentThreadsSortValidator, videoCommentThreadsSortValidator,
videoRatesSortValidator,
userSubscriptionsSortValidator, userSubscriptionsSortValidator,
videoChannelsSearchSortValidator, videoChannelsSearchSortValidator,
accountsBlocklistSortValidator, accountsBlocklistSortValidator,

View File

@ -22,6 +22,7 @@ import { logger } from '../../helpers/logger'
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup' import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup'
import { Redis } from '../../lib/redis' import { Redis } from '../../lib/redis'
import { UserModel } from '../../models/account/user' import { UserModel } from '../../models/account/user'
import { AccountModel } from '../../models/account/account'
import { areValidationErrors } from './utils' import { areValidationErrors } from './utils'
import { ActorModel } from '../../models/activitypub/actor' import { ActorModel } from '../../models/activitypub/actor'
@ -317,6 +318,20 @@ const userAutocompleteValidator = [
param('search').isString().not().isEmpty().withMessage('Should have a search parameter') 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 { export {
@ -335,7 +350,8 @@ export {
usersResetPasswordValidator, usersResetPasswordValidator,
usersAskSendVerifyEmailValidator, usersAskSendVerifyEmailValidator,
usersVerifyEmailValidator, usersVerifyEmailValidator,
userAutocompleteValidator userAutocompleteValidator,
ensureAuthUserOwnsAccountValidator
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,7 +1,8 @@
import * as express from 'express' import * as express from 'express'
import 'express-validator' 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 { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
import { isRatingValid } from '../../../helpers/custom-validators/video-rates'
import { doesVideoExist, isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' import { doesVideoExist, isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { areValidationErrors } from '../utils' 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 { export {
videoUpdateRateValidator, videoUpdateRateValidator,
getAccountVideoRateValidator getAccountVideoRateValidator,
videoRatingValidator
} }

View File

@ -7,8 +7,10 @@ import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers'
import { VideoModel } from '../video/video' import { VideoModel } from '../video/video'
import { AccountModel } from './account' import { AccountModel } from './account'
import { ActorModel } from '../activitypub/actor' import { ActorModel } from '../activitypub/actor'
import { throwIfNotValid } from '../utils' import { throwIfNotValid, getSort } from '../utils'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 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. Account rates per video.
@ -88,6 +90,38 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
return AccountVideoRateModel.findOne(options) 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) { static loadLocalAndPopulateVideo (rateType: VideoRateType, accountName: string, videoId: number, transaction?: Transaction) {
const options: IFindOptions<AccountVideoRateModel> = { const options: IFindOptions<AccountVideoRateModel> = {
where: { where: {
@ -185,4 +219,11 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
else if (type === 'dislike') await VideoModel.increment({ dislikes: -deleted }, options) else if (type === 'dislike') await VideoModel.increment({ dislikes: -deleted }, options)
}) })
} }
toFormattedJSON (): AccountVideoRate {
return {
video: this.Video.toFormattedJSON(),
rating: this.type
}
}
} }

View File

@ -8,6 +8,7 @@ import {
createUser, createUser,
deleteMe, deleteMe,
flushTests, flushTests,
getAccountRatings,
getBlacklistedVideosList, getBlacklistedVideosList,
getMyUserInformation, getMyUserInformation,
getMyUserVideoQuotaUsed, getMyUserVideoQuotaUsed,
@ -32,7 +33,7 @@ import {
updateUser, updateUser,
uploadVideo, uploadVideo,
userLogin userLogin
} from '../../../../shared/utils/index' } from '../../../../shared/utils'
import { follow } from '../../../../shared/utils/server/follows' import { follow } from '../../../../shared/utils/server/follows'
import { setAccessTokensToServers } from '../../../../shared/utils/users/login' import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
import { getMyVideos } from '../../../../shared/utils/videos/videos' import { getMyVideos } from '../../../../shared/utils/videos/videos'
@ -137,6 +138,35 @@ describe('Test users', function () {
expect(rating.rating).to.equal('like') 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 () { it('Should not be able to remove the video with an incorrect token', async function () {
await removeVideo(server.url, 'bad_token', videoId, 401) await removeVideo(server.url, 'bad_token', videoId, 401)
}) })

View File

@ -1,5 +1,6 @@
export * from './rate/user-video-rate-update.model' export * from './rate/user-video-rate-update.model'
export * from './rate/user-video-rate.model' export * from './rate/user-video-rate.model'
export * from './rate/account-video-rate.model'
export * from './rate/user-video-rate.type' export * from './rate/user-video-rate.type'
export * from './abuse/video-abuse-state.model' export * from './abuse/video-abuse-state.model'
export * from './abuse/video-abuse-create.model' export * from './abuse/video-abuse-create.model'

View File

@ -0,0 +1,7 @@
import { UserVideoRateType } from './user-video-rate.type'
import { Video } from '../video.model'
export interface AccountVideoRate {
video: Video
rating: UserVideoRateType
}

View File

@ -15,6 +15,7 @@ export * from './server/servers'
export * from './videos/services' export * from './videos/services'
export * from './videos/video-playlists' export * from './videos/video-playlists'
export * from './users/users' export * from './users/users'
export * from './users/accounts'
export * from './videos/video-abuses' export * from './videos/video-abuses'
export * from './videos/video-blacklist' export * from './videos/video-blacklist'
export * from './videos/video-channels' export * from './videos/video-channels'

View File

@ -1,5 +1,6 @@
/* tslint:disable:no-unused-expression */ /* tslint:disable:no-unused-expression */
import * as request from 'supertest'
import { expect } from 'chai' import { expect } from 'chai'
import { existsSync, readdir } from 'fs-extra' import { existsSync, readdir } from 'fs-extra'
import { join } from 'path' 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 { export {
getAccount, getAccount,
expectAccountFollows, expectAccountFollows,
getAccountsList, getAccountsList,
checkActorFilesWereRemoved checkActorFilesWereRemoved,
getAccountRatings
} }

View File

@ -1344,6 +1344,35 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/VideoChannel' $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': '/videos/{id}/comment-threads':
get: get:
summary: Get the comment threads of a video by its id summary: Get the comment threads of a video by its id
@ -2142,6 +2171,16 @@ components:
required: required:
- id - id
- rating - rating
VideoRating:
properties:
video:
$ref: '#/components/schemas/Video'
rating:
type: number
description: 'Rating of the video'
required:
- video
- rating
RegisterUser: RegisterUser:
properties: properties:
username: username: