import { meRouter } from './me'
import { deleteUserToken } from '../../../lib/oauth-model'
import { myBlocklistRouter } from './my-blocklist'
+import { myVideosHistoryRouter } from './my-history'
const auditLogger = auditLoggerFactory('users')
const usersRouter = express.Router()
usersRouter.use('/', myBlocklistRouter)
+usersRouter.use('/', myVideosHistoryRouter)
usersRouter.use('/', meRouter)
usersRouter.get('/autocomplete',
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)
--- /dev/null
+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()
+}
return isBooleanValid(value)
}
+function isUserVideosHistoryEnabledValid (value: any) {
+ return isBooleanValid(value)
+}
+
function isUserAutoPlayVideoValid (value: any) {
return isBooleanValid(value)
}
// ---------------------------------------------------------------------------
export {
+ isUserVideosHistoryEnabledValid,
isUserBlockedValid,
isUserPasswordValid,
isUserBlockedReasonValid,
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 295
+const LAST_MIGRATION_VERSION = 300
// ---------------------------------------------------------------------------
--- /dev/null
+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
+}
export * from './webfinger'
export * from './search'
export * from './server'
+export * from './user-history'
--- /dev/null
+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
+}
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'),
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()
}
]
-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',
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)
+ }
}
isUserUsernameValid,
isUserVideoQuotaDailyValid,
isUserVideoQuotaValid,
- isUserWebTorrentEnabledValid
+ isUserWebTorrentEnabledValid,
+ isUserVideosHistoryEnabledValid
} from '../../helpers/custom-validators/users'
import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
import { OAuthTokenModel } from '../oauth/oauth-token'
@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'))
]
}
- return [ [ field, direction ], lastSort ]
+ return [ field.split('.').concat([ direction ]), lastSort ]
}
function getSortOnModel (model: any, value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
accountId?: number
videoChannelId?: number
trendingDays?: number
- user?: UserModel
+ user?: UserModel,
+ historyOfUser?: UserModel
}
@Scopes({
query.subQuery = false
}
+ if (options.historyOfUser) {
+ query.include.push({
+ model: UserVideoHistoryModel,
+ required: true,
+ where: {
+ userId: options.historyOfUser.id
+ }
+ })
+ }
+
return query
},
[ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
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')
videoChannelId: options.videoChannelId,
includeLocalVideos: options.includeLocalVideos,
user: options.user,
+ historyOfUser: options.historyOfUser,
trendingDays
}
}
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)
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',
import * as chai from 'chai'
import 'mocha'
import {
+ checkBadCountPagination,
+ checkBadStartPagination,
flushTests,
killallServers,
+ makeGetRequest,
makePostBodyRequest,
makePutBodyRequest,
runServer,
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
// ---------------------------------------------------------------
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 () {
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
+ })
})
})
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
let video1UUID: string
let video2UUID: string
let video3UUID: string
+ let video3WatchedDate: Date
+ let userAccessToken: string
before(async function () {
this.timeout(30000)
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 () {
})
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 () {
}
})
+ 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 ])
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
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'
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,
-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
}