diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index adbf69781..aa01ea1eb 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -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 index 000000000..f2b6f7cae --- /dev/null +++ b/server/helpers/custom-validators/video-rates.ts @@ -0,0 +1,5 @@ +function isRatingValid (value: any) { + return value === 'like' || value === 'dislike' +} + +export { isRatingValid } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 78dd7cb9d..097199f84 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -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' ], diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index ea59fbf73..44295c325 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -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, diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index 4be446732..35f41c450 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts @@ -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 } // --------------------------------------------------------------------------- diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts index 280385912..e79d80e97 100644 --- a/server/middlewares/validators/videos/video-rates.ts +++ b/server/middlewares/validators/videos/video-rates.ts @@ -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 } diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts index e5d39582b..f462df4b3 100644 --- a/server/models/account/account-video-rate.ts +++ b/server/models/account/account-video-rate.ts @@ -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 { return AccountVideoRateModel.findOne(options) } + static listByAccountForApi (options: { + start: number, + count: number, + sort: string, + type?: string, + accountId: number + }) { + const query: IFindOptions = { + 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 = { where: { @@ -185,4 +219,11 @@ export class AccountVideoRateModel extends Model { else if (type === 'dislike') await VideoModel.increment({ dislikes: -deleted }, options) }) } + + toFormattedJSON (): AccountVideoRate { + return { + video: this.Video.toFormattedJSON(), + rating: this.type + } + } } diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index c4465d541..bc069a7be 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts @@ -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) }) diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 9cf861048..e3d78220e 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -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 index 000000000..e789367dc --- /dev/null +++ b/shared/models/videos/rate/account-video-rate.model.ts @@ -0,0 +1,7 @@ +import { UserVideoRateType } from './user-video-rate.type' +import { Video } from '../video.model' + +export interface AccountVideoRate { + video: Video + rating: UserVideoRateType +} diff --git a/shared/utils/index.ts b/shared/utils/index.ts index c09565d95..469546872 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -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' diff --git a/shared/utils/users/accounts.ts b/shared/utils/users/accounts.ts index 388eb6973..54d66ac2a 100644 --- a/shared/utils/users/accounts.ts +++ b/shared/utils/users/accounts.ts @@ -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 } diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 46c66a101..833a98d3b 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -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: