From 6d8524702874120a4667269a81a61e3c7c5e300d Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 22 Dec 2017 09:14:50 +0100 Subject: Create comment on replied mastodon statutes --- server/controllers/api/videos/comment.ts | 88 ++++++++++++++++++++ .../custom-validators/activitypub/activity.ts | 4 +- .../activitypub/video-comments.ts | 40 +++++++++ server/initializers/constants.ts | 3 + server/initializers/database.ts | 4 +- server/lib/activitypub/process/process-create.ts | 53 +++++++++++- server/models/video/video-comment.ts | 95 ++++++++++++++++++++++ server/models/video/video.ts | 12 +++ 8 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 server/controllers/api/videos/comment.ts create mode 100644 server/helpers/custom-validators/activitypub/video-comments.ts create mode 100644 server/models/video/video-comment.ts (limited to 'server') diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts new file mode 100644 index 000000000..b69aa5d40 --- /dev/null +++ b/server/controllers/api/videos/comment.ts @@ -0,0 +1,88 @@ +// 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(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 +// } +// } diff --git a/server/helpers/custom-validators/activitypub/activity.ts b/server/helpers/custom-validators/activitypub/activity.ts index c402800a4..f2e137061 100644 --- a/server/helpers/custom-validators/activitypub/activity.ts +++ b/server/helpers/custom-validators/activitypub/activity.ts @@ -6,6 +6,7 @@ import { isActivityPubUrlValid } from './misc' import { isDislikeActivityValid, isLikeActivityValid } from './rate' import { isUndoActivityValid } from './undo' import { isVideoChannelDeleteActivityValid, isVideoChannelUpdateActivityValid } from './video-channels' +import { isVideoCommentCreateActivityValid } from './video-comments' import { isVideoFlagValid, isVideoTorrentCreateActivityValid, @@ -59,7 +60,8 @@ function checkCreateActivity (activity: any) { return isViewActivityValid(activity) || isDislikeActivityValid(activity) || isVideoTorrentCreateActivityValid(activity) || - isVideoFlagValid(activity) + isVideoFlagValid(activity) || + isVideoCommentCreateActivityValid(activity) } function checkUpdateActivity (activity: any) { diff --git a/server/helpers/custom-validators/activitypub/video-comments.ts b/server/helpers/custom-validators/activitypub/video-comments.ts new file mode 100644 index 000000000..489ff27de --- /dev/null +++ b/server/helpers/custom-validators/activitypub/video-comments.ts @@ -0,0 +1,40 @@ +import * as validator from 'validator' +import { exists, isDateValid } from '../misc' +import { isActivityPubUrlValid, isBaseActivityValid } from './misc' +import * as sanitizeHtml from 'sanitize-html' + +function isVideoCommentCreateActivityValid (activity: any) { + return isBaseActivityValid(activity, 'Create') && + isVideoCommentObjectValid(activity.object) +} + +function isVideoCommentObjectValid (comment: any) { + return comment.type === 'Note' && + isActivityPubUrlValid(comment.id) && + sanitizeCommentHTML(comment) && + isCommentContentValid(comment.content) && + isActivityPubUrlValid(comment.inReplyTo) && + isDateValid(comment.published) && + isActivityPubUrlValid(comment.url) +} + +// --------------------------------------------------------------------------- + +export { + isVideoCommentCreateActivityValid +} + +// --------------------------------------------------------------------------- + +function sanitizeCommentHTML (comment: any) { + return sanitizeHtml(comment.content, { + allowedTags: [ 'b', 'i', 'em', 'span', 'a' ], + allowedAttributes: { + 'a': [ 'href' ] + } + }) +} + +function isCommentContentValid (content: any) { + return exists(content) && validator.isLength('' + content, { min: 1 }) +} diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 100a77622..c8b21d10d 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -175,6 +175,9 @@ const CONSTRAINTS_FIELDS = { }, VIDEO_EVENTS: { COUNT: { min: 0 } + }, + COMMENT: { + URL: { min: 3, max: 2000 } // Length } } diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 0b3f695f7..852db68a0 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -18,6 +18,7 @@ import { VideoModel } from '../models/video/video' import { VideoAbuseModel } from '../models/video/video-abuse' import { VideoBlacklistModel } from '../models/video/video-blacklist' import { VideoChannelModel } from '../models/video/video-channel' +import { VideoCommentModel } from '../models/video/video-comment' import { VideoFileModel } from '../models/video/video-file' import { VideoShareModel } from '../models/video/video-share' import { VideoTagModel } from '../models/video/video-tag' @@ -73,7 +74,8 @@ async function initDatabaseModels (silent: boolean) { VideoFileModel, VideoBlacklistModel, VideoTagModel, - VideoModel + VideoModel, + VideoCommentModel ]) if (!silent) logger.info('Database %s is ready.', dbname) diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts index 1ddd817db..102e54b19 100644 --- a/server/lib/activitypub/process/process-create.ts +++ b/server/lib/activitypub/process/process-create.ts @@ -1,6 +1,7 @@ import * as Bluebird from 'bluebird' import { ActivityCreate, VideoTorrentObject } from '../../../../shared' import { DislikeObject, VideoAbuseObject, ViewObject } from '../../../../shared/models/activitypub/objects' +import { VideoCommentObject } from '../../../../shared/models/activitypub/objects/video-comment-object' import { VideoRateType } from '../../../../shared/models/videos' import { logger, retryTransactionWrapper } from '../../../helpers' import { sequelizeTypescript } from '../../../initializers' @@ -9,6 +10,7 @@ import { ActorModel } from '../../../models/activitypub/actor' import { TagModel } from '../../../models/video/tag' import { VideoModel } from '../../../models/video/video' import { VideoAbuseModel } from '../../../models/video/video-abuse' +import { VideoCommentModel } from '../../../models/video/video-comment' import { VideoFileModel } from '../../../models/video/video-file' import { getOrCreateActorAndServerAndModel } from '../actor' import { forwardActivity } from '../send/misc' @@ -28,6 +30,8 @@ async function processCreateActivity (activity: ActivityCreate) { return processCreateVideo(actor, activity) } else if (activityType === 'Flag') { return processCreateVideoAbuse(actor, activityObject as VideoAbuseObject) + } else if (activityType === 'Note') { + return processCreateVideoComment(actor, activity) } logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) @@ -184,7 +188,7 @@ function createVideoDislike (byActor: ActorModel, activity: ActivityCreate) { }) } -async function processCreateView (byAccount: ActorModel, activity: ActivityCreate) { +async function processCreateView (byActor: ActorModel, activity: ActivityCreate) { const view = activity.object as ViewObject const video = await VideoModel.loadByUrlAndPopulateAccount(view.object) @@ -198,7 +202,7 @@ async function processCreateView (byAccount: ActorModel, activity: ActivityCreat if (video.isOwned()) { // Don't resend the activity to the sender - const exceptions = [ byAccount ] + const exceptions = [ byActor ] await forwardActivity(activity, undefined, exceptions) } } @@ -236,3 +240,48 @@ function addRemoteVideoAbuse (actor: ActorModel, videoAbuseToCreateData: VideoAb logger.info('Remote abuse for video uuid %s created', videoAbuseToCreateData.object) }) } + +function processCreateVideoComment (byActor: ActorModel, activity: ActivityCreate) { + const options = { + arguments: [ byActor, activity ], + errorMessage: 'Cannot create video comment with many retries.' + } + + return retryTransactionWrapper(createVideoComment, options) +} + +function createVideoComment (byActor: ActorModel, activity: ActivityCreate) { + const comment = activity.object as VideoCommentObject + const byAccount = byActor.Account + + if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url) + + return sequelizeTypescript.transaction(async t => { + const video = await VideoModel.loadByUrl(comment.inReplyTo, t) + + // This is a new thread + if (video) { + return VideoCommentModel.create({ + url: comment.id, + text: comment.content, + originCommentId: null, + inReplyToComment: null, + videoId: video.id, + actorId: byActor.id + }, { transaction: t }) + } + + const inReplyToComment = await VideoCommentModel.loadByUrl(comment.inReplyTo, t) + if (!inReplyToComment) throw new Error('Unknown replied comment ' + comment.inReplyTo) + + const originCommentId = inReplyToComment.originCommentId || inReplyToComment.id + return VideoCommentModel.create({ + url: comment.id, + text: comment.content, + originCommentId, + inReplyToCommentId: inReplyToComment.id, + videoId: inReplyToComment.videoId, + actorId: byActor.id + }, { transaction: t }) + }) +} diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts new file mode 100644 index 000000000..92c0c6112 --- /dev/null +++ b/server/models/video/video-comment.ts @@ -0,0 +1,95 @@ +import * as Sequelize from 'sequelize' +import { + AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, IFindOptions, Is, IsUUID, Model, Table, + UpdatedAt +} from 'sequelize-typescript' +import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub' +import { CONSTRAINTS_FIELDS } from '../../initializers' +import { ActorModel } from '../activitypub/actor' +import { throwIfNotValid } from '../utils' +import { VideoModel } from './video' + +@Table({ + tableName: 'videoComment', + indexes: [ + { + fields: [ 'videoId' ] + } + ] +}) +export class VideoCommentModel extends Model { + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @AllowNull(false) + @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max)) + url: string + + @AllowNull(false) + @Column(DataType.TEXT) + text: string + + @ForeignKey(() => VideoCommentModel) + @Column + originCommentId: number + + @BelongsTo(() => VideoCommentModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'CASCADE' + }) + OriginVideoComment: VideoCommentModel + + @ForeignKey(() => VideoCommentModel) + @Column + inReplyToCommentId: number + + @BelongsTo(() => VideoCommentModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'CASCADE' + }) + InReplyToVideoComment: VideoCommentModel + + @ForeignKey(() => VideoModel) + @Column + videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Video: VideoModel + + @ForeignKey(() => ActorModel) + @Column + actorId: number + + @BelongsTo(() => ActorModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Actor: ActorModel + + static loadByUrl (url: string, t?: Sequelize.Transaction) { + const query: IFindOptions = { + where: { + url + } + } + + if (t !== undefined) query.transaction = t + + return VideoCommentModel.findOne(query) + } +} diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 8c49bc3af..b6a2ce6b5 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -491,6 +491,18 @@ export class VideoModel extends Model { return VideoModel.findById(id) } + static loadByUrl (url: string, t?: Sequelize.Transaction) { + const query: IFindOptions = { + where: { + url + } + } + + if (t !== undefined) query.transaction = t + + return VideoModel.findOne(query) + } + static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) { const query: IFindOptions = { where: { -- cgit v1.2.3