-// import * as express from 'express'
-// import { logger, getFormattedObjects } from '../../../helpers'
-// import {
-// authenticate,
-// ensureUserHasRight,
-// videosBlacklistAddValidator,
-// videosBlacklistRemoveValidator,
-// paginationValidator,
-// blacklistSortValidator,
-// setBlacklistSort,
-// setPagination,
-// asyncMiddleware
-// } from '../../../middlewares'
-// import { BlacklistedVideo, UserRight } from '../../../../shared'
-// import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
-//
-// const videoCommentRouter = express.Router()
-//
-// videoCommentRouter.get('/:videoId/comment',
-// authenticate,
-// ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
-// asyncMiddleware(listVideoCommentsThreadsValidator),
-// asyncMiddleware(listVideoCommentsThreads)
-// )
-//
-// videoCommentRouter.post('/:videoId/comment',
-// authenticate,
-// ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
-// asyncMiddleware(videosBlacklistAddValidator),
-// asyncMiddleware(addVideoToBlacklist)
-// )
-//
-// videoCommentRouter.get('/blacklist',
-// authenticate,
-// ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
-// paginationValidator,
-// blacklistSortValidator,
-// setBlacklistSort,
-// setPagination,
-// asyncMiddleware(listBlacklist)
-// )
-//
-// videoCommentRouter.delete('/:videoId/blacklist',
-// authenticate,
-// ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
-// asyncMiddleware(videosBlacklistRemoveValidator),
-// asyncMiddleware(removeVideoFromBlacklistController)
-// )
-//
-// // ---------------------------------------------------------------------------
-//
-// export {
-// videoCommentRouter
-// }
-//
-// // ---------------------------------------------------------------------------
-//
-// async function addVideoToBlacklist (req: express.Request, res: express.Response, next: express.NextFunction) {
-// const videoInstance = res.locals.video
-//
-// const toCreate = {
-// videoId: videoInstance.id
-// }
-//
-// await VideoBlacklistModel.create(toCreate)
-// return res.type('json').status(204).end()
-// }
-//
-// async function listBlacklist (req: express.Request, res: express.Response, next: express.NextFunction) {
-// const resultList = await VideoBlacklistModel.listForApi(req.query.start, req.query.count, req.query.sort)
-//
-// return res.json(getFormattedObjects<BlacklistedVideo, VideoBlacklistModel>(resultList.data, resultList.total))
-// }
-//
-// async function removeVideoFromBlacklistController (req: express.Request, res: express.Response, next: express.NextFunction) {
-// const blacklistedVideo = res.locals.blacklistedVideo as VideoBlacklistModel
-//
-// try {
-// await blacklistedVideo.destroy()
-//
-// logger.info('Video %s removed from blacklist.', res.locals.video.uuid)
-//
-// return res.sendStatus(204)
-// } catch (err) {
-// logger.error('Some error while removing video %s from blacklist.', res.locals.video.uuid, err)
-// throw err
-// }
-// }
+import * as express from 'express'
+import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model'
+import { getFormattedObjects, retryTransactionWrapper } from '../../../helpers'
+import { sequelizeTypescript } from '../../../initializers'
+import { buildFormattedCommentTree, createVideoComment } from '../../../lib/video-comment'
+import { asyncMiddleware, authenticate, paginationValidator, setPagination, setVideoCommentThreadsSort } from '../../../middlewares'
+import { videoCommentThreadsSortValidator } from '../../../middlewares/validators'
+import {
+ addVideoCommentReplyValidator, addVideoCommentThreadValidator, listVideoCommentThreadsValidator,
+ listVideoThreadCommentsValidator
+} from '../../../middlewares/validators/video-comments'
+import { VideoCommentModel } from '../../../models/video/video-comment'
+
+const videoCommentRouter = express.Router()
+
+videoCommentRouter.get('/:videoId/comment-threads',
+ paginationValidator,
+ videoCommentThreadsSortValidator,
+ setVideoCommentThreadsSort,
+ setPagination,
+ asyncMiddleware(listVideoCommentThreadsValidator),
+ asyncMiddleware(listVideoThreads)
+)
+videoCommentRouter.get('/:videoId/comment-threads/:threadId',
+ asyncMiddleware(listVideoThreadCommentsValidator),
+ asyncMiddleware(listVideoThreadComments)
+)
+
+videoCommentRouter.post('/:videoId/comment-threads',
+ authenticate,
+ asyncMiddleware(addVideoCommentThreadValidator),
+ asyncMiddleware(addVideoCommentThreadRetryWrapper)
+)
+videoCommentRouter.post('/:videoId/comments/:commentId',
+ authenticate,
+ asyncMiddleware(addVideoCommentReplyValidator),
+ asyncMiddleware(addVideoCommentReplyRetryWrapper)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+ videoCommentRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function listVideoThreads (req: express.Request, res: express.Response, next: express.NextFunction) {
+ const resultList = await VideoCommentModel.listThreadsForApi(res.locals.video.id, req.query.start, req.query.count, req.query.sort)
+
+ return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function listVideoThreadComments (req: express.Request, res: express.Response, next: express.NextFunction) {
+ const resultList = await VideoCommentModel.listThreadCommentsForApi(res.locals.video.id, res.locals.videoCommentThread.id)
+
+ return res.json(buildFormattedCommentTree(resultList))
+}
+
+async function addVideoCommentThreadRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) {
+ const options = {
+ arguments: [ req, res ],
+ errorMessage: 'Cannot insert the video comment thread with many retries.'
+ }
+
+ const comment = await retryTransactionWrapper(addVideoCommentThread, options)
+
+ res.json({
+ comment: {
+ id: comment.id
+ }
+ }).end()
+}
+
+function addVideoCommentThread (req: express.Request, res: express.Response) {
+ const videoCommentInfo: VideoCommentCreate = req.body
+
+ return sequelizeTypescript.transaction(async t => {
+ return createVideoComment({
+ text: videoCommentInfo.text,
+ inReplyToComment: null,
+ video: res.locals.video,
+ actorId: res.locals.oauth.token.User.Account.Actor.id
+ }, t)
+ })
+}
+
+async function addVideoCommentReplyRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) {
+ const options = {
+ arguments: [ req, res ],
+ errorMessage: 'Cannot insert the video comment reply with many retries.'
+ }
+
+ const comment = await retryTransactionWrapper(addVideoCommentReply, options)
+
+ res.json({
+ comment: {
+ id: comment.id
+ }
+ }).end()
+}
+
+function addVideoCommentReply (req: express.Request, res: express.Response, next: express.NextFunction) {
+ const videoCommentInfo: VideoCommentCreate = req.body
+
+ return sequelizeTypescript.transaction(async t => {
+ return createVideoComment({
+ text: videoCommentInfo.text,
+ inReplyToComment: res.locals.videoComment.id,
+ video: res.locals.video,
+ actorId: res.locals.oauth.token.User.Account.Actor.id
+ }, t)
+ })
+}
import { abuseVideoRouter } from './abuse'
import { blacklistRouter } from './blacklist'
import { videoChannelRouter } from './channel'
+import { videoCommentRouter } from './comment'
import { rateVideoRouter } from './rate'
const videosRouter = express.Router()
videosRouter.use('/', blacklistRouter)
videosRouter.use('/', rateVideoRouter)
videosRouter.use('/', videoChannelRouter)
+videosRouter.use('/', videoCommentRouter)
videosRouter.get('/categories', listVideoCategories)
videosRouter.get('/licences', listVideoLicences)
--- /dev/null
+import 'express-validator'
+import 'multer'
+import * as validator from 'validator'
+import { CONSTRAINTS_FIELDS } from '../../initializers'
+
+const VIDEO_COMMENTS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_COMMENTS
+
+function isValidVideoCommentText (value: string) {
+ return value === null || validator.isLength(value, VIDEO_COMMENTS_CONSTRAINTS_FIELDS.TEXT)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ isValidVideoCommentText
+}
VIDEO_ABUSES: [ 'id', 'createdAt' ],
VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ],
VIDEOS: [ 'name', 'duration', 'createdAt', 'views', 'likes' ],
+ VIDEO_COMMENT_THREADS: [ 'createdAt' ],
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
FOLLOWERS: [ 'createdAt' ],
FOLLOWING: [ 'createdAt' ]
VIDEO_EVENTS: {
COUNT: { min: 0 }
},
- COMMENT: {
+ VIDEO_COMMENTS: {
+ TEXT: { min: 2, max: 3000 }, // Length
URL: { min: 3, max: 2000 } // Length
}
}
import { ActorFollowModel } from '../../models/activitypub/actor-follow'
import { VideoModel } from '../../models/video/video'
import { VideoAbuseModel } from '../../models/video/video-abuse'
+import { VideoCommentModel } from '../../models/video/video-comment'
function getVideoActivityPubUrl (video: VideoModel) {
return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
}
-function getVideoChannelActivityPubUrl (videoChannelUUID: string) {
- return CONFIG.WEBSERVER.URL + '/video-channels/' + videoChannelUUID
+function getVideoCommentActivityPubUrl (video: VideoModel, videoComment: VideoCommentModel) {
+ return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid + '#comment-' + videoComment.id
}
-function getApplicationActivityPubUrl () {
- return CONFIG.WEBSERVER.URL + '/application/peertube'
+function getVideoChannelActivityPubUrl (videoChannelUUID: string) {
+ return CONFIG.WEBSERVER.URL + '/video-channels/' + videoChannelUUID
}
function getAccountActivityPubUrl (accountName: string) {
}
export {
- getApplicationActivityPubUrl,
getVideoActivityPubUrl,
getVideoChannelActivityPubUrl,
getAccountActivityPubUrl,
getUndoActivityPubUrl,
getVideoViewActivityPubUrl,
getVideoLikeActivityPubUrl,
- getVideoDislikeActivityPubUrl
+ getVideoDislikeActivityPubUrl,
+ getVideoCommentActivityPubUrl
}
--- /dev/null
+import * as Sequelize from 'sequelize'
+import { ResultList } from '../../shared/models'
+import { VideoCommentThread } from '../../shared/models/videos/video-comment.model'
+import { VideoModel } from '../models/video/video'
+import { VideoCommentModel } from '../models/video/video-comment'
+import { getVideoCommentActivityPubUrl } from './activitypub'
+
+async function createVideoComment (obj: {
+ text: string,
+ inReplyToComment: number,
+ video: VideoModel
+ actorId: number
+}, t: Sequelize.Transaction) {
+ let originCommentId: number = null
+ if (obj.inReplyToComment) {
+ const repliedComment = await VideoCommentModel.loadById(obj.inReplyToComment)
+ if (!repliedComment) throw new Error('Unknown replied comment.')
+
+ originCommentId = repliedComment.originCommentId || repliedComment.id
+ }
+
+ const comment = await VideoCommentModel.create({
+ text: obj.text,
+ originCommentId,
+ inReplyToComment: obj.inReplyToComment,
+ videoId: obj.video.id,
+ actorId: obj.actorId
+ }, { transaction: t })
+
+ comment.set('url', getVideoCommentActivityPubUrl(obj.video, comment))
+
+ return comment.save({ transaction: t })
+}
+
+function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThread {
+ // Comments are sorted by id ASC
+ const comments = resultList.data
+
+ const comment = comments.shift()
+ const thread: VideoCommentThread = {
+ comment: comment.toFormattedJSON(),
+ children: []
+ }
+ const idx = {
+ [comment.id]: thread
+ }
+
+ while (comments.length !== 0) {
+ const childComment = comments.shift()
+
+ const childCommentThread: VideoCommentThread = {
+ comment: childComment.toFormattedJSON(),
+ children: []
+ }
+
+ const parentCommentThread = idx[childComment.inReplyToCommentId]
+ if (!parentCommentThread) {
+ const msg = `Cannot format video thread tree, parent ${childComment.inReplyToCommentId} not found for child ${childComment.id}`
+ throw new Error(msg)
+ }
+
+ parentCommentThread.children.push(childCommentThread)
+ idx[childComment.id] = childCommentThread
+ }
+
+ return thread
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ createVideoComment,
+ buildFormattedCommentTree
+}
return next()
}
+function setVideoCommentThreadsSort (req: express.Request, res: express.Response, next: express.NextFunction) {
+ if (!req.query.sort) req.query.sort = '-createdAt'
+
+ return next()
+}
+
function setFollowersSort (req: express.Request, res: express.Response, next: express.NextFunction) {
if (!req.query.sort) req.query.sort = '-createdAt'
setBlacklistSort,
setFollowersSort,
setFollowingSort,
- setJobsSort
+ setJobsSort,
+ setVideoCommentThreadsSort
}
const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
+const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
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)
const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS)
const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
+const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS)
blacklistSortValidator,
followersSortValidator,
followingSortValidator,
- jobsSortValidator
+ jobsSortValidator,
+ videoCommentThreadsSortValidator
}
// ---------------------------------------------------------------------------
--- /dev/null
+import * as express from 'express'
+import { body, param } from 'express-validator/check'
+import { logger } from '../../helpers'
+import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc'
+import { isValidVideoCommentText } from '../../helpers/custom-validators/video-comments'
+import { isVideoExist } from '../../helpers/custom-validators/videos'
+import { VideoCommentModel } from '../../models/video/video-comment'
+import { areValidationErrors } from './utils'
+
+const listVideoCommentThreadsValidator = [
+ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking blacklistRemove parameters.', { parameters: req.params })
+
+ if (areValidationErrors(req, res)) return
+ if (!await isVideoExist(req.params.videoId, res)) return
+
+ return next()
+ }
+]
+
+const listVideoThreadCommentsValidator = [
+ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
+ param('threadId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid threadId'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking blacklistRemove parameters.', { parameters: req.params })
+
+ if (areValidationErrors(req, res)) return
+ if (!await isVideoExist(req.params.videoId, res)) return
+ if (!await isVideoCommentThreadExist(req.params.threadId, req.params.videoId, res)) return
+
+ return next()
+ }
+]
+
+const addVideoCommentThreadValidator = [
+ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
+ body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking blacklistRemove parameters.', { parameters: req.params })
+
+ if (areValidationErrors(req, res)) return
+ if (!await isVideoExist(req.params.videoId, res)) return
+
+ return next()
+ }
+]
+
+const addVideoCommentReplyValidator = [
+ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
+ param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'),
+ body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking blacklistRemove parameters.', { parameters: req.params })
+
+ if (areValidationErrors(req, res)) return
+ if (!await isVideoExist(req.params.videoId, res)) return
+ if (!await isVideoCommentExist(req.params.commentId, req.params.videoId, res)) return
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+ listVideoCommentThreadsValidator,
+ listVideoThreadCommentsValidator,
+ addVideoCommentThreadValidator,
+ addVideoCommentReplyValidator
+}
+
+// ---------------------------------------------------------------------------
+
+async function isVideoCommentThreadExist (id: number, videoId: number, res: express.Response) {
+ const videoComment = await VideoCommentModel.loadById(id)
+
+ if (!videoComment) {
+ res.status(404)
+ .json({ error: 'Video comment thread not found' })
+ .end()
+
+ return false
+ }
+
+ if (videoComment.videoId !== videoId) {
+ res.status(400)
+ .json({ error: 'Video comment is associated to this video.' })
+ .end()
+
+ return false
+ }
+
+ if (videoComment.inReplyToCommentId !== null) {
+ res.status(400)
+ .json({ error: 'Video comment is not a thread.' })
+ .end()
+
+ return false
+ }
+
+ res.locals.videoCommentThread = videoComment
+ return true
+}
+
+async function isVideoCommentExist (id: number, videoId: number, res: express.Response) {
+ const videoComment = await VideoCommentModel.loadById(id)
+
+ if (!videoComment) {
+ res.status(404)
+ .json({ error: 'Video comment thread not found' })
+ .end()
+
+ return false
+ }
+
+ if (videoComment.videoId !== videoId) {
+ res.status(400)
+ .json({ error: 'Video comment is associated to this video.' })
+ .end()
+
+ return false
+ }
+
+ res.locals.videoComment = videoComment
+ return true
+}
import * as Sequelize from 'sequelize'
import {
- AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, IFindOptions, Is, IsUUID, Model, Table,
+ AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
UpdatedAt
} from 'sequelize-typescript'
+import { VideoComment } from '../../../shared/models/videos/video-comment.model'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub'
import { CONSTRAINTS_FIELDS } from '../../initializers'
import { ActorModel } from '../activitypub/actor'
-import { throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../utils'
import { VideoModel } from './video'
+enum ScopeNames {
+ WITH_ACTOR = 'WITH_ACTOR'
+}
+
+@Scopes({
+ [ScopeNames.WITH_ACTOR]: {
+ include: [
+ () => ActorModel
+ ]
+ }
+})
@Table({
tableName: 'videoComment',
indexes: [
{
fields: [ 'videoId' ]
+ },
+ {
+ fields: [ 'videoId', 'originCommentId' ]
}
]
})
})
Actor: ActorModel
+ @AfterDestroy
+ static sendDeleteIfOwned (instance: VideoCommentModel) {
+ // TODO
+ return undefined
+ }
+
+ static loadById (id: number, t?: Sequelize.Transaction) {
+ const query: IFindOptions<VideoCommentModel> = {
+ where: {
+ id
+ }
+ }
+
+ if (t !== undefined) query.transaction = t
+
+ return VideoCommentModel.findOne(query)
+ }
+
static loadByUrl (url: string, t?: Sequelize.Transaction) {
const query: IFindOptions<VideoCommentModel> = {
where: {
return VideoCommentModel.findOne(query)
}
+
+ static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
+ const query = {
+ offset: start,
+ limit: count,
+ order: [ getSort(sort) ],
+ where: {
+ videoId
+ }
+ }
+
+ return VideoCommentModel
+ .scope([ ScopeNames.WITH_ACTOR ])
+ .findAndCountAll(query)
+ .then(({ rows, count }) => {
+ return { total: count, data: rows }
+ })
+ }
+
+ static listThreadCommentsForApi (videoId: number, threadId: number) {
+ const query = {
+ order: [ 'id', 'ASC' ],
+ where: {
+ videoId,
+ [ Sequelize.Op.or ]: [
+ { id: threadId },
+ { originCommentId: threadId }
+ ]
+ }
+ }
+
+ return VideoCommentModel
+ .scope([ ScopeNames.WITH_ACTOR ])
+ .findAndCountAll(query)
+ .then(({ rows, count }) => {
+ return { total: count, data: rows }
+ })
+ }
+
+ toFormattedJSON () {
+ return {
+ id: this.id,
+ url: this.url,
+ text: this.text,
+ threadId: this.originCommentId || this.id,
+ inReplyToCommentId: this.inReplyToCommentId,
+ videoId: this.videoId,
+ createdAt: this.createdAt,
+ updatedAt: this.updatedAt
+ } as VideoComment
+ }
}
--- /dev/null
+export interface VideoComment {
+ id: number
+ url: string
+ text: string
+ threadId: number
+ inReplyToCommentId: number
+ videoId: number
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+export interface VideoCommentThread {
+ comment: VideoComment
+ children: VideoCommentThread[]
+}
+
+export interface VideoCommentCreate {
+ text: string
+}