Add history on server side

Add ability to disable, clear and list user videos history
pull/1535/head
Chocobozzz 2018-12-17 15:52:38 +01:00
parent 583cd0d212
commit 8b9a525a18
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
19 changed files with 385 additions and 29 deletions

View File

@ -38,6 +38,7 @@ import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../h
import { meRouter } from './me'
import { deleteUserToken } from '../../../lib/oauth-model'
import { myBlocklistRouter } from './my-blocklist'
import { myVideosHistoryRouter } from './my-history'
const auditLogger = auditLoggerFactory('users')
@ -55,6 +56,7 @@ const askSendEmailLimiter = new RateLimit({
const usersRouter = express.Router()
usersRouter.use('/', myBlocklistRouter)
usersRouter.use('/', myVideosHistoryRouter)
usersRouter.use('/', meRouter)
usersRouter.get('/autocomplete',

View File

@ -330,6 +330,7 @@ async function updateMe (req: express.Request, res: express.Response, next: expr
if (body.nsfwPolicy !== undefined) user.nsfwPolicy = body.nsfwPolicy
if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled
if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled
await sequelizeTypescript.transaction(async t => {
const userAccount = await AccountModel.load(user.Account.id)

View File

@ -0,0 +1,57 @@
import * as express from 'express'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
paginationValidator,
setDefaultPagination,
userHistoryRemoveValidator
} from '../../../middlewares'
import { UserModel } from '../../../models/account/user'
import { getFormattedObjects } from '../../../helpers/utils'
import { UserVideoHistoryModel } from '../../../models/account/user-video-history'
import { sequelizeTypescript } from '../../../initializers'
const myVideosHistoryRouter = express.Router()
myVideosHistoryRouter.get('/me/history/videos',
authenticate,
paginationValidator,
setDefaultPagination,
asyncMiddleware(listMyVideosHistory)
)
myVideosHistoryRouter.post('/me/history/videos/remove',
authenticate,
userHistoryRemoveValidator,
asyncRetryTransactionMiddleware(removeUserHistory)
)
// ---------------------------------------------------------------------------
export {
myVideosHistoryRouter
}
// ---------------------------------------------------------------------------
async function listMyVideosHistory (req: express.Request, res: express.Response) {
const user: UserModel = res.locals.oauth.token.User
const resultList = await UserVideoHistoryModel.listForApi(user, req.query.start, req.query.count)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function removeUserHistory (req: express.Request, res: express.Response) {
const user: UserModel = res.locals.oauth.token.User
const beforeDate = req.body.beforeDate || null
await sequelizeTypescript.transaction(t => {
return UserVideoHistoryModel.removeHistoryBefore(user, beforeDate, t)
})
// Do not send the delete to other instances, we delete OUR copy of this video abuse
return res.type('json').status(204).end()
}

View File

@ -46,6 +46,10 @@ function isUserWebTorrentEnabledValid (value: any) {
return isBooleanValid(value)
}
function isUserVideosHistoryEnabledValid (value: any) {
return isBooleanValid(value)
}
function isUserAutoPlayVideoValid (value: any) {
return isBooleanValid(value)
}
@ -73,6 +77,7 @@ function isAvatarFile (files: { [ fieldname: string ]: Express.Multer.File[] } |
// ---------------------------------------------------------------------------
export {
isUserVideosHistoryEnabledValid,
isUserBlockedValid,
isUserPasswordValid,
isUserBlockedReasonValid,

View File

@ -16,7 +16,7 @@ let config: IConfig = require('config')
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 295
const LAST_MIGRATION_VERSION = 300
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,27 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction,
queryInterface: Sequelize.QueryInterface,
sequelize: Sequelize.Sequelize,
db: any
}): Promise<void> {
{
const data = {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
}
await utils.queryInterface.addColumn('user', 'videosHistoryEnabled', data)
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -12,3 +12,4 @@ export * from './videos'
export * from './webfinger'
export * from './search'
export * from './server'
export * from './user-history'

View File

@ -0,0 +1,30 @@
import * as express from 'express'
import 'express-validator'
import { body, param, query } from 'express-validator/check'
import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils'
import { ActorFollowModel } from '../../models/activitypub/actor-follow'
import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
import { UserModel } from '../../models/account/user'
import { CONFIG } from '../../initializers'
import { isDateValid, toArray } from '../../helpers/custom-validators/misc'
const userHistoryRemoveValidator = [
body('beforeDate')
.optional()
.custom(isDateValid).withMessage('Should have a valid before date'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking userHistoryRemoveValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
userHistoryRemoveValidator
}

View File

@ -4,6 +4,7 @@ import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
import { isVideoExist } from '../../../helpers/custom-validators/videos'
import { areValidationErrors } from '../utils'
import { logger } from '../../../helpers/logger'
import { UserModel } from '../../../models/account/user'
const videoWatchingValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
@ -17,6 +18,12 @@ const videoWatchingValidator = [
if (areValidationErrors(req, res)) return
if (!await isVideoExist(req.params.videoId, res, 'id')) return
const user = res.locals.oauth.token.User as UserModel
if (user.videosHistoryEnabled === false) {
logger.warn('Cannot set videos to watch by user %d: videos history is disabled.', user.id)
return res.status(409).end()
}
return next()
}
]

View File

@ -1,6 +1,7 @@
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Min, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { VideoModel } from '../video/video'
import { UserModel } from './user'
import { Transaction, Op, DestroyOptions } from 'sequelize'
@Table({
tableName: 'userVideoHistory',
@ -52,4 +53,34 @@ export class UserVideoHistoryModel extends Model<UserVideoHistoryModel> {
onDelete: 'CASCADE'
})
User: UserModel
static listForApi (user: UserModel, start: number, count: number) {
return VideoModel.listForApi({
start,
count,
sort: '-UserVideoHistories.updatedAt',
nsfw: null, // All
includeLocalVideos: true,
withFiles: false,
user,
historyOfUser: user
})
}
static removeHistoryBefore (user: UserModel, beforeDate: string, t: Transaction) {
const query: DestroyOptions = {
where: {
userId: user.id
},
transaction: t
}
if (beforeDate) {
query.where.updatedAt = {
[Op.lt]: beforeDate
}
}
return UserVideoHistoryModel.destroy(query)
}
}

View File

@ -32,7 +32,8 @@ import {
isUserUsernameValid,
isUserVideoQuotaDailyValid,
isUserVideoQuotaValid,
isUserWebTorrentEnabledValid
isUserWebTorrentEnabledValid,
isUserVideosHistoryEnabledValid
} from '../../helpers/custom-validators/users'
import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
import { OAuthTokenModel } from '../oauth/oauth-token'
@ -114,6 +115,12 @@ export class UserModel extends Model<UserModel> {
@Column
webTorrentEnabled: boolean
@AllowNull(false)
@Default(true)
@Is('UserVideosHistoryEnabled', value => throwIfNotValid(value, isUserVideosHistoryEnabledValid, 'Videos history enabled'))
@Column
videosHistoryEnabled: boolean
@AllowNull(false)
@Default(true)
@Is('UserAutoPlayVideo', value => throwIfNotValid(value, isUserAutoPlayVideoValid, 'auto play video boolean'))

View File

@ -29,7 +29,7 @@ function getVideoSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
]
}
return [ [ field, direction ], lastSort ]
return [ field.split('.').concat([ direction ]), lastSort ]
}
function getSortOnModel (model: any, value: string, lastSort: string[] = [ 'id', 'ASC' ]) {

View File

@ -153,7 +153,8 @@ type AvailableForListIDsOptions = {
accountId?: number
videoChannelId?: number
trendingDays?: number
user?: UserModel
user?: UserModel,
historyOfUser?: UserModel
}
@Scopes({
@ -416,6 +417,16 @@ type AvailableForListIDsOptions = {
query.subQuery = false
}
if (options.historyOfUser) {
query.include.push({
model: UserVideoHistoryModel,
required: true,
where: {
userId: options.historyOfUser.id
}
})
}
return query
},
[ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
@ -987,7 +998,8 @@ export class VideoModel extends Model<VideoModel> {
videoChannelId?: number,
followerActorId?: number
trendingDays?: number,
user?: UserModel
user?: UserModel,
historyOfUser?: UserModel
}, countVideos = true) {
if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
throw new Error('Try to filter all-local but no user has not the see all videos right')
@ -1026,6 +1038,7 @@ export class VideoModel extends Model<VideoModel> {
videoChannelId: options.videoChannelId,
includeLocalVideos: options.includeLocalVideos,
user: options.user,
historyOfUser: options.historyOfUser,
trendingDays
}
@ -1341,7 +1354,7 @@ export class VideoModel extends Model<VideoModel> {
}
const [ count, rowsId ] = await Promise.all([
countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve(undefined),
countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve<number>(undefined),
VideoModel.scope(idsScope).findAll(query)
])
const ids = rowsId.map(r => r.id)

View File

@ -308,6 +308,14 @@ describe('Test users API validators', function () {
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
})
it('Should fail with an invalid videosHistoryEnabled attribute', async function () {
const fields = {
videosHistoryEnabled: -1
}
await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
})
it('Should fail with an non authenticated user', async function () {
const fields = {
currentPassword: 'my super password',

View File

@ -3,8 +3,11 @@
import * as chai from 'chai'
import 'mocha'
import {
checkBadCountPagination,
checkBadStartPagination,
flushTests,
killallServers,
makeGetRequest,
makePostBodyRequest,
makePutBodyRequest,
runServer,
@ -16,7 +19,9 @@ import {
const expect = chai.expect
describe('Test videos history API validator', function () {
let path: string
let watchingPath: string
let myHistoryPath = '/api/v1/users/me/history/videos'
let myHistoryRemove = myHistoryPath + '/remove'
let server: ServerInfo
// ---------------------------------------------------------------
@ -33,14 +38,14 @@ describe('Test videos history API validator', function () {
const res = await uploadVideo(server.url, server.accessToken, {})
const videoUUID = res.body.video.uuid
path = '/api/v1/videos/' + videoUUID + '/watching'
watchingPath = '/api/v1/videos/' + videoUUID + '/watching'
})
describe('When notifying a user is watching a video', function () {
it('Should fail with an unauthenticated user', async function () {
const fields = { currentTime: 5 }
await makePutBodyRequest({ url: server.url, path, fields, statusCodeExpected: 401 })
await makePutBodyRequest({ url: server.url, path: watchingPath, fields, statusCodeExpected: 401 })
})
it('Should fail with an incorrect video id', async function () {
@ -58,13 +63,68 @@ describe('Test videos history API validator', function () {
it('Should fail with a bad current time', async function () {
const fields = { currentTime: 'hello' }
await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 })
await makePutBodyRequest({ url: server.url, path: watchingPath, fields, token: server.accessToken, statusCodeExpected: 400 })
})
it('Should succeed with the correct parameters', async function () {
const fields = { currentTime: 5 }
await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 204 })
await makePutBodyRequest({ url: server.url, path: watchingPath, fields, token: server.accessToken, statusCodeExpected: 204 })
})
})
describe('When listing user videos history', function () {
it('Should fail with a bad start pagination', async function () {
await checkBadStartPagination(server.url, myHistoryPath, server.accessToken)
})
it('Should fail with a bad count pagination', async function () {
await checkBadCountPagination(server.url, myHistoryPath, server.accessToken)
})
it('Should fail with an unauthenticated user', async function () {
await makeGetRequest({ url: server.url, path: myHistoryPath, statusCodeExpected: 401 })
})
it('Should succeed with the correct params', async function () {
await makeGetRequest({ url: server.url, token: server.accessToken, path: myHistoryPath, statusCodeExpected: 200 })
})
})
describe('When removing user videos history', function () {
it('Should fail with an unauthenticated user', async function () {
await makePostBodyRequest({ url: server.url, path: myHistoryPath + '/remove', statusCodeExpected: 401 })
})
it('Should fail with a bad beforeDate parameter', async function () {
const body = { beforeDate: '15' }
await makePostBodyRequest({
url: server.url,
token: server.accessToken,
path: myHistoryRemove,
fields: body,
statusCodeExpected: 400
})
})
it('Should succeed with a valid beforeDate param', async function () {
const body = { beforeDate: new Date().toISOString() }
await makePostBodyRequest({
url: server.url,
token: server.accessToken,
path: myHistoryRemove,
fields: body,
statusCodeExpected: 204
})
})
it('Should succeed without body', async function () {
await makePostBodyRequest({
url: server.url,
token: server.accessToken,
path: myHistoryRemove,
statusCodeExpected: 204
})
})
})

View File

@ -3,17 +3,21 @@
import * as chai from 'chai'
import 'mocha'
import {
createUser,
flushTests,
getVideosListWithToken,
getVideoWithToken,
killallServers, makePutBodyRequest,
runServer, searchVideoWithToken,
killallServers,
runServer,
searchVideoWithToken,
ServerInfo,
setAccessTokensToServers,
uploadVideo
updateMyUser,
uploadVideo,
userLogin
} from '../../../../shared/utils'
import { Video, VideoDetails } from '../../../../shared/models/videos'
import { userWatchVideo } from '../../../../shared/utils/videos/video-history'
import { listMyVideosHistory, removeMyVideosHistory, userWatchVideo } from '../../../../shared/utils/videos/video-history'
const expect = chai.expect
@ -22,6 +26,8 @@ describe('Test videos history', function () {
let video1UUID: string
let video2UUID: string
let video3UUID: string
let video3WatchedDate: Date
let userAccessToken: string
before(async function () {
this.timeout(30000)
@ -46,6 +52,13 @@ describe('Test videos history', function () {
const res = await uploadVideo(server.url, server.accessToken, { name: 'video 3' })
video3UUID = res.body.video.uuid
}
const user = {
username: 'user_1',
password: 'super password'
}
await createUser(server.url, server.accessToken, user.username, user.password)
userAccessToken = await userLogin(server, user)
})
it('Should get videos, without watching history', async function () {
@ -62,8 +75,8 @@ describe('Test videos history', function () {
})
it('Should watch the first and second video', async function () {
await userWatchVideo(server.url, server.accessToken, video1UUID, 3)
await userWatchVideo(server.url, server.accessToken, video2UUID, 8)
await userWatchVideo(server.url, server.accessToken, video1UUID, 3)
})
it('Should return the correct history when listing, searching and getting videos', async function () {
@ -117,6 +130,68 @@ describe('Test videos history', function () {
}
})
it('Should have these videos when listing my history', async function () {
video3WatchedDate = new Date()
await userWatchVideo(server.url, server.accessToken, video3UUID, 2)
const res = await listMyVideosHistory(server.url, server.accessToken)
expect(res.body.total).to.equal(3)
const videos: Video[] = res.body.data
expect(videos[0].name).to.equal('video 3')
expect(videos[1].name).to.equal('video 1')
expect(videos[2].name).to.equal('video 2')
})
it('Should not have videos history on another user', async function () {
const res = await listMyVideosHistory(server.url, userAccessToken)
expect(res.body.total).to.equal(0)
expect(res.body.data).to.have.lengthOf(0)
})
it('Should clear my history', async function () {
await removeMyVideosHistory(server.url, server.accessToken, video3WatchedDate.toISOString())
})
it('Should have my history cleared', async function () {
const res = await listMyVideosHistory(server.url, server.accessToken)
expect(res.body.total).to.equal(1)
const videos: Video[] = res.body.data
expect(videos[0].name).to.equal('video 3')
})
it('Should disable videos history', async function () {
await updateMyUser({
url: server.url,
accessToken: server.accessToken,
videosHistoryEnabled: false
})
await userWatchVideo(server.url, server.accessToken, video2UUID, 8, 409)
})
it('Should re-enable videos history', async function () {
await updateMyUser({
url: server.url,
accessToken: server.accessToken,
videosHistoryEnabled: true
})
await userWatchVideo(server.url, server.accessToken, video1UUID, 8)
const res = await listMyVideosHistory(server.url, server.accessToken)
expect(res.body.total).to.equal(2)
const videos: Video[] = res.body.data
expect(videos[0].name).to.equal('video 1')
expect(videos[1].name).to.equal('video 3')
})
after(async function () {
killallServers([ server ])

View File

@ -3,9 +3,12 @@ import { NSFWPolicyType } from '../videos/nsfw-policy.type'
export interface UserUpdateMe {
displayName?: string
description?: string
nsfwPolicy?: NSFWPolicyType,
webTorrentEnabled?: boolean,
nsfwPolicy?: NSFWPolicyType
webTorrentEnabled?: boolean
autoPlayVideo?: boolean
videosHistoryEnabled?: boolean
email?: string
currentPassword?: string
password?: string

View File

@ -162,14 +162,15 @@ function unblockUser (url: string, userId: number | string, accessToken: string,
function updateMyUser (options: {
url: string
accessToken: string,
currentPassword?: string,
newPassword?: string,
nsfwPolicy?: NSFWPolicyType,
email?: string,
accessToken: string
currentPassword?: string
newPassword?: string
nsfwPolicy?: NSFWPolicyType
email?: string
autoPlayVideo?: boolean
displayName?: string,
displayName?: string
description?: string
videosHistoryEnabled?: boolean
}) {
const path = '/api/v1/users/me'
@ -181,6 +182,9 @@ function updateMyUser (options: {
if (options.email !== undefined && options.email !== null) toSend['email'] = options.email
if (options.description !== undefined && options.description !== null) toSend['description'] = options.description
if (options.displayName !== undefined && options.displayName !== null) toSend['displayName'] = options.displayName
if (options.videosHistoryEnabled !== undefined && options.videosHistoryEnabled !== null) {
toSend['videosHistoryEnabled'] = options.videosHistoryEnabled
}
return makePutBodyRequest({
url: options.url,

View File

@ -1,14 +1,39 @@
import { makePutBodyRequest } from '../requests/requests'
import { makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requests/requests'
function userWatchVideo (url: string, token: string, videoId: number | string, currentTime: number) {
function userWatchVideo (url: string, token: string, videoId: number | string, currentTime: number, statusCodeExpected = 204) {
const path = '/api/v1/videos/' + videoId + '/watching'
const fields = { currentTime }
return makePutBodyRequest({ url, path, token, fields, statusCodeExpected: 204 })
return makePutBodyRequest({ url, path, token, fields, statusCodeExpected })
}
function listMyVideosHistory (url: string, token: string) {
const path = '/api/v1/users/me/history/videos'
return makeGetRequest({
url,
path,
token,
statusCodeExpected: 200
})
}
function removeMyVideosHistory (url: string, token: string, beforeDate?: string) {
const path = '/api/v1/users/me/history/videos/remove'
return makePostBodyRequest({
url,
path,
token,
fields: beforeDate ? { beforeDate } : {},
statusCodeExpected: 204
})
}
// ---------------------------------------------------------------------------
export {
userWatchVideo
userWatchVideo,
listMyVideosHistory,
removeMyVideosHistory
}