1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +02:00

Implement auto tag on comments and videos

* Comments and videos can be automatically tagged using core rules or
   watched word lists
 * These tags can be used to automatically filter videos and comments
 * Introduce a new video comment policy where comments must be approved
   first
 * Comments may have to be approved if the user auto block them using
   core rules or watched word lists
 * Implement FEP-5624 to federate reply control policies
This commit is contained in:
Chocobozzz 2024-03-29 14:25:03 +01:00 committed by Chocobozzz
parent b3e39df59e
commit 29329d6c45
241 changed files with 8090 additions and 1399 deletions

View file

@ -0,0 +1,82 @@
import { AutomaticTagPolicy, CommentAutomaticTagPoliciesUpdate, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
import { setAccountAutomaticTagsPolicy } from '@server/lib/automatic-tags/automatic-tags.js'
import {
manageAccountAutomaticTagsValidator,
updateAutomaticTagPoliciesValidator
} from '@server/middlewares/validators/automatic-tags.js'
import { getServerActor } from '@server/models/application/application.js'
import express from 'express'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
ensureUserHasRight
} from '../../middlewares/index.js'
const automaticTagRouter = express.Router()
automaticTagRouter.use(apiRateLimiter)
automaticTagRouter.get('/policies/accounts/:accountName/comments',
authenticate,
asyncMiddleware(manageAccountAutomaticTagsValidator),
asyncMiddleware(getAutomaticTagPolicies)
)
automaticTagRouter.put('/policies/accounts/:accountName/comments',
authenticate,
asyncMiddleware(manageAccountAutomaticTagsValidator),
asyncMiddleware(updateAutomaticTagPoliciesValidator),
asyncMiddleware(updateAutomaticTagPolicies)
)
// ---------------------------------------------------------------------------
automaticTagRouter.get('/accounts/:accountName/available',
authenticate,
asyncMiddleware(manageAccountAutomaticTagsValidator),
asyncMiddleware(getAccountAutomaticTagAvailable)
)
automaticTagRouter.get('/server/available',
authenticate,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_AUTO_TAGS),
asyncMiddleware(getServerAutomaticTagAvailable)
)
// ---------------------------------------------------------------------------
export {
automaticTagRouter
}
// ---------------------------------------------------------------------------
async function getAutomaticTagPolicies (req: express.Request, res: express.Response) {
const result = await AutomaticTagger.getAutomaticTagPolicies(res.locals.account)
return res.json(result)
}
async function updateAutomaticTagPolicies (req: express.Request, res: express.Response) {
await setAccountAutomaticTagsPolicy({
account: res.locals.account,
policy: AutomaticTagPolicy.REVIEW_COMMENT,
tags: (req.body as CommentAutomaticTagPoliciesUpdate).review
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function getAccountAutomaticTagAvailable (req: express.Request, res: express.Response) {
const result = await AutomaticTagger.getAutomaticTagAvailable(res.locals.account)
return res.json(result)
}
async function getServerAutomaticTagAvailable (req: express.Request, res: express.Response) {
const result = await AutomaticTagger.getAutomaticTagAvailable((await getServerActor()).Account)
return res.json(result)
}

View file

@ -1,9 +1,10 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import cors from 'cors'
import express from 'express'
import { logger } from '@server/helpers/logger.js'
import { HttpStatusCode } from '@peertube/peertube-models'
import { abuseRouter } from './abuse.js'
import { accountsRouter } from './accounts.js'
import { automaticTagRouter } from './automatic-tags.js'
import { blocklistRouter } from './blocklist.js'
import { bulkRouter } from './bulk.js'
import { configRouter } from './config.js'
@ -21,6 +22,7 @@ import { videoChannelSyncRouter } from './video-channel-sync.js'
import { videoChannelRouter } from './video-channel.js'
import { videoPlaylistRouter } from './video-playlist.js'
import { videosRouter } from './videos/index.js'
import { watchedWordsRouter } from './watched-words.js'
const apiRouter = express.Router()
@ -49,6 +51,8 @@ apiRouter.use('/plugins', pluginRouter)
apiRouter.use('/custom-pages', customPageRouter)
apiRouter.use('/blocklist', blocklistRouter)
apiRouter.use('/runners', runnersRouter)
apiRouter.use('/watched-words', watchedWordsRouter)
apiRouter.use('/automatic-tags', automaticTagRouter)
apiRouter.use('/ping', pong)
apiRouter.use('/*', badRequest)

View file

@ -1,16 +1,17 @@
import 'multer'
import express from 'express'
import { pick } from '@peertube/peertube-core-utils'
import {
ActorImageType,
UserVideoRate as FormattedUserVideoRate,
HttpStatusCode,
UserUpdateMe,
UserVideoQuota,
UserVideoRate as FormattedUserVideoRate
UserVideoQuota
} from '@peertube/peertube-models'
import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { UserAuditView, auditLoggerFactory, getAuditIdFromRes } from '@server/helpers/audit-logger.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoCommentModel } from '@server/models/video/video-comment.js'
import express from 'express'
import 'multer'
import { createReqFiles } from '../../../helpers/express-utils.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { CONFIG } from '../../../initializers/config.js'
@ -34,6 +35,7 @@ import { updateAvatarValidator } from '../../../middlewares/validators/actor-ima
import {
deleteMeValidator,
getMyVideoImportsValidator,
listCommentsOnUserVideosValidator,
usersVideosValidator,
videoImportsSortValidator,
videosSortValidator
@ -75,6 +77,16 @@ meRouter.get('/me/videos/imports',
asyncMiddleware(getUserVideoImports)
)
meRouter.get('/me/videos/comments',
authenticate,
paginationValidator,
videosSortValidator,
setDefaultVideosSort,
setDefaultPagination,
asyncMiddleware(listCommentsOnUserVideosValidator),
asyncMiddleware(listCommentsOnUserVideos)
)
meRouter.get('/me/videos',
authenticate,
paginationValidator,
@ -82,7 +94,7 @@ meRouter.get('/me/videos',
setDefaultVideosSort,
setDefaultPagination,
asyncMiddleware(usersVideosValidator),
asyncMiddleware(getUserVideos)
asyncMiddleware(listUserVideos)
)
meRouter.get('/me/videos/:videoId/rating',
@ -117,7 +129,7 @@ export {
// ---------------------------------------------------------------------------
async function getUserVideos (req: express.Request, res: express.Response) {
async function listUserVideos (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const apiOptions = await Hooks.wrapObject({
@ -145,6 +157,36 @@ async function getUserVideos (req: express.Request, res: express.Response) {
return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes }))
}
async function listCommentsOnUserVideos (req: express.Request, res: express.Response) {
const userAccount = res.locals.oauth.token.User.Account
const options = {
...pick(req.query, [
'start',
'count',
'sort',
'search',
'searchAccount',
'searchVideo',
'autoTagOneOf'
]),
autoTagOfAccountId: userAccount.id,
videoAccountOwnerId: userAccount.id,
heldForReview: req.query.isHeldForReview,
videoChannelOwnerId: res.locals.videoChannel?.id,
videoId: res.locals.videoAll?.id
}
const resultList = await VideoCommentModel.listCommentsForApi(options)
return res.json({
total: resultList.total,
data: resultList.data.map(c => c.toFormattedForAdminOrUserJSON())
})
}
async function getUserVideoImports (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const resultList = await VideoImportModel.listUserVideoImportsForApi({

View file

@ -1,19 +1,21 @@
import express from 'express'
import { pick } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
ResultList,
ThreadsResultList,
UserRight,
VideoCommentCreate,
VideoCommentPolicy,
VideoCommentThreads
} from '@peertube/peertube-models'
import { getServerActor } from '@server/models/application/application.js'
import { MCommentFormattable } from '@server/types/models/index.js'
import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger.js'
import express from 'express'
import { CommentAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../../helpers/audit-logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { Notifier } from '../../../lib/notifier/index.js'
import { Hooks } from '../../../lib/plugins/hooks.js'
import { buildFormattedCommentTree, createVideoComment, removeComment } from '../../../lib/video-comment.js'
import { approveComment, buildFormattedCommentTree, createLocalVideoComment, removeComment } from '../../../lib/video-comment.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
@ -27,14 +29,14 @@ import {
import {
addVideoCommentReplyValidator,
addVideoCommentThreadValidator,
listVideoCommentsValidator,
approveVideoCommentValidator,
listAllVideoCommentsForAdminValidator,
listVideoCommentThreadsValidator,
listVideoThreadCommentsValidator,
removeVideoCommentValidator,
videoCommentsValidator,
videoCommentThreadsSortValidator
videoCommentThreadsSortValidator,
videoCommentsValidator
} from '../../../middlewares/validators/index.js'
import { AccountModel } from '../../../models/account/account.js'
import { VideoCommentModel } from '../../../models/video/video-comment.js'
const auditLogger = auditLoggerFactory('comments')
@ -71,6 +73,12 @@ videoCommentRouter.delete('/:videoId/comments/:commentId',
asyncRetryTransactionMiddleware(removeVideoComment)
)
videoCommentRouter.post('/:videoId/comments/:commentId/approve',
authenticate,
asyncMiddleware(approveVideoCommentValidator),
asyncMiddleware(approveVideoComment)
)
videoCommentRouter.get('/comments',
authenticate,
ensureUserHasRight(UserRight.SEE_ALL_COMMENTS),
@ -78,7 +86,7 @@ videoCommentRouter.get('/comments',
videoCommentsValidator,
setDefaultSort,
setDefaultPagination,
listVideoCommentsValidator,
asyncMiddleware(listAllVideoCommentsForAdminValidator),
asyncMiddleware(listComments)
)
@ -92,22 +100,29 @@ export {
async function listComments (req: express.Request, res: express.Response) {
const options = {
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
...pick(req.query, [
'start',
'count',
'sort',
'isLocal',
'onLocalVideo',
'search',
'searchAccount',
'searchVideo',
'autoTagOneOf'
]),
isLocal: req.query.isLocal,
onLocalVideo: req.query.onLocalVideo,
search: req.query.search,
searchAccount: req.query.searchAccount,
searchVideo: req.query.searchVideo
videoId: res.locals.onlyImmutableVideo?.id,
videoChannelOwnerId: res.locals.videoChannel?.id,
autoTagOfAccountId: (await getServerActor()).Account.id,
heldForReview: undefined
}
const resultList = await VideoCommentModel.listCommentsForApi(options)
return res.json({
total: resultList.total,
data: resultList.data.map(c => c.toFormattedAdminJSON())
data: resultList.data.map(c => c.toFormattedForAdminOrUserJSON())
})
}
@ -117,10 +132,9 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
let resultList: ThreadsResultList<MCommentFormattable>
if (video.commentsEnabled === true) {
if (video.commentsPolicy !== VideoCommentPolicy.DISABLED) {
const apiOptions = await Hooks.wrapObject({
videoId: video.id,
isVideoOwned: video.isOwned(),
video,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
@ -152,9 +166,9 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
let resultList: ResultList<MCommentFormattable>
if (video.commentsEnabled === true) {
if (video.commentsPolicy !== VideoCommentPolicy.DISABLED) {
const apiOptions = await Hooks.wrapObject({
videoId: video.id,
video,
threadId: res.locals.videoCommentThread.id,
user
}, 'filter:api.video-thread-comments.list.params')
@ -184,15 +198,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
async function addVideoCommentThread (req: express.Request, res: express.Response) {
const videoCommentInfo: VideoCommentCreate = req.body
const comment = await sequelizeTypescript.transaction(async t => {
const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
return createVideoComment({
text: videoCommentInfo.text,
inReplyToComment: null,
video: res.locals.videoAll,
account
}, t)
const comment = await createLocalVideoComment({
text: videoCommentInfo.text,
inReplyToComment: null,
video: res.locals.videoAll,
user: res.locals.oauth.token.User
})
Notifier.Instance.notifyOnNewComment(comment)
@ -206,15 +216,11 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
async function addVideoCommentReply (req: express.Request, res: express.Response) {
const videoCommentInfo: VideoCommentCreate = req.body
const comment = await sequelizeTypescript.transaction(async t => {
const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
return createVideoComment({
text: videoCommentInfo.text,
inReplyToComment: res.locals.videoCommentFull,
video: res.locals.videoAll,
account
}, t)
const comment = await createLocalVideoComment({
text: videoCommentInfo.text,
inReplyToComment: res.locals.videoCommentFull,
video: res.locals.videoAll,
user: res.locals.oauth.token.User
})
Notifier.Instance.notifyOnNewComment(comment)
@ -226,13 +232,17 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
}
async function removeVideoComment (req: express.Request, res: express.Response) {
const videoCommentInstance = res.locals.videoCommentFull
const comment = res.locals.videoCommentFull
await removeComment(videoCommentInstance, req, res)
await removeComment(comment, req, res)
auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function approveVideoComment (req: express.Request, res: express.Response) {
await approveComment(res.locals.videoCommentFull)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View file

@ -1,8 +1,10 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, ThumbnailType, VideoPrivacy, VideoPrivacyType, VideoUpdate } from '@peertube/peertube-models'
import { HttpStatusCode, ThumbnailType, VideoCommentPolicy, VideoPrivacy, VideoPrivacyType, VideoUpdate } from '@peertube/peertube-models'
import { exists } from '@server/helpers/custom-validators/misc.js'
import { changeVideoChannelShare } from '@server/lib/activitypub/share.js'
import { isNewVideoPrivacyForFederation, isPrivacyForFederation } from '@server/lib/activitypub/videos/federate.js'
import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js'
import { updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js'
import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
import { addVideoJobsAfterUpdate } from '@server/lib/video-jobs.js'
@ -65,6 +67,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
// Refresh video since thumbnails to prevent concurrent updates
const video = await VideoModel.loadFull(videoFromReq.id, t)
const oldName = video.name
const oldDescription = video.description
const oldVideoChannel = video.VideoChannel
@ -77,7 +80,6 @@ async function updateVideo (req: express.Request, res: express.Response) {
'waitTranscoding',
'support',
'description',
'commentsEnabled',
'downloadEnabled'
]
@ -85,6 +87,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
if (videoInfoToUpdate[key] !== undefined) video.set(key, videoInfoToUpdate[key])
}
// Special treatment for comments policy to support deprecated commentsEnabled attribute
if (videoInfoToUpdate.commentsPolicy !== undefined) {
video.commentsPolicy = videoInfoToUpdate.commentsPolicy
} else if (videoInfoToUpdate.commentsEnabled === true) {
video.commentsPolicy = VideoCommentPolicy.ENABLED
} else if (videoInfoToUpdate.commentsEnabled === false) {
video.commentsPolicy = VideoCommentPolicy.DISABLED
}
if (videoInfoToUpdate.originallyPublishedAt !== undefined) {
video.originallyPublishedAt = videoInfoToUpdate.originallyPublishedAt
? new Date(videoInfoToUpdate.originallyPublishedAt)
@ -142,6 +153,11 @@ async function updateVideo (req: express.Request, res: express.Response) {
})
}
if (oldName !== video.name || oldDescription !== video.description) {
const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction: t })
await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction: t })
}
await autoBlacklistVideoIfNeeded({
video: videoInstanceUpdated,
user: res.locals.oauth.token.User,

View file

@ -0,0 +1,162 @@
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { Awaitable } from '@peertube/peertube-typescript-utils'
import {
addWatchedWordsListValidatorFactory,
getWatchedWordsListValidatorFactory,
manageAccountWatchedWordsListValidator,
updateWatchedWordsListValidatorFactory
} from '@server/middlewares/validators/watched-words.js'
import { getServerActor } from '@server/models/application/application.js'
import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
import { MAccountId } from '@server/types/models/index.js'
import express from 'express'
import { getFormattedObjects } from '../../helpers/utils.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate, ensureUserHasRight, paginationValidator,
setDefaultPagination,
setDefaultSort,
watchedWordsListsSortValidator
} from '../../middlewares/index.js'
const watchedWordsRouter = express.Router()
watchedWordsRouter.use(apiRateLimiter)
{
const common = [
authenticate,
paginationValidator,
watchedWordsListsSortValidator,
setDefaultSort,
setDefaultPagination
]
watchedWordsRouter.get('/accounts/:accountName/lists',
...common,
asyncMiddleware(manageAccountWatchedWordsListValidator),
asyncMiddleware(listWatchedWordsListsFactory(res => res.locals.account))
)
watchedWordsRouter.get('/server/lists',
...common,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
asyncMiddleware(listWatchedWordsListsFactory(() => getServerActor().then(a => a.Account)))
)
}
// ---------------------------------------------------------------------------
{
watchedWordsRouter.post('/accounts/:accountName/lists',
authenticate,
asyncMiddleware(manageAccountWatchedWordsListValidator),
asyncMiddleware(addWatchedWordsListValidatorFactory(res => res.locals.account)),
asyncMiddleware(addWatchedWordsListFactory(res => res.locals.account))
)
watchedWordsRouter.post('/server/lists',
authenticate,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
asyncMiddleware(addWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
asyncMiddleware(addWatchedWordsListFactory(() => getServerActor().then(a => a.Account)))
)
}
// ---------------------------------------------------------------------------
{
watchedWordsRouter.put('/accounts/:accountName/lists/:listId',
authenticate,
asyncMiddleware(manageAccountWatchedWordsListValidator),
asyncMiddleware(getWatchedWordsListValidatorFactory(res => res.locals.account)),
asyncMiddleware(updateWatchedWordsListValidatorFactory(res => res.locals.account)),
asyncMiddleware(updateWatchedWordsList)
)
watchedWordsRouter.put('/server/lists/:listId',
authenticate,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
asyncMiddleware(getWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
asyncMiddleware(updateWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
asyncMiddleware(updateWatchedWordsList)
)
}
// ---------------------------------------------------------------------------
{
watchedWordsRouter.delete('/accounts/:accountName/lists/:listId',
authenticate,
asyncMiddleware(manageAccountWatchedWordsListValidator),
asyncMiddleware(getWatchedWordsListValidatorFactory(res => res.locals.account)),
asyncMiddleware(deleteWatchedWordsList)
)
watchedWordsRouter.delete('/server/lists/:listId',
authenticate,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
asyncMiddleware(getWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
asyncMiddleware(deleteWatchedWordsList)
)
}
// ---------------------------------------------------------------------------
export {
watchedWordsRouter
}
// ---------------------------------------------------------------------------
function listWatchedWordsListsFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
return async (req: express.Request, res: express.Response) => {
const resultList = await WatchedWordsListModel.listForAPI({
accountId: (await accountGetter(res)).id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
}
function addWatchedWordsListFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
return async (req: express.Request, res: express.Response) => {
const list = await WatchedWordsListModel.createList({
accountId: (await accountGetter(res)).id,
listName: req.body.listName,
words: req.body.words
})
return res.json({
watchedWordsList: {
id: list.id
}
})
}
}
async function updateWatchedWordsList (req: express.Request, res: express.Response) {
const list = res.locals.watchedWordsList
await list.updateList({
listName: req.body.listName,
words: req.body.words
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function deleteWatchedWordsList (req: express.Request, res: express.Response) {
const list = res.locals.watchedWordsList
await list.destroy()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}