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

Improve NSFW system

* Add NSFW flags to videos so the publisher can add more NSFW context
 * Add NSFW summary to videos, similar to content warning system so the
   publisher has a free text to describe NSFW aspect of its video
 * Add additional "warn" NSFW policy: the video thumbnail is not blurred
   and we display a tag below the video miniature, the video player
   includes the NSFW warning (with context if available) and it also
   prevent autoplay
 * "blur" NSFW settings inherits "warn" policy and also blur the video
   thumbnail
 * Add NSFW flag settings to users so they can have more granular
   control about what content they want to hide, warn or display
This commit is contained in:
Chocobozzz 2025-04-24 14:51:07 +02:00
parent fac6b15ada
commit dd4027a10f
No known key found for this signature in database
GPG key ID: 583A612D890159BE
181 changed files with 5081 additions and 2061 deletions

View file

@ -3,7 +3,7 @@ import { ActorFollowModel } from '@server/models/actor/actor-follow.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import express from 'express'
import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils.js'
import { buildNSFWFilters, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils.js'
import { getFormattedObjects } from '../../helpers/utils.js'
import { JobQueue } from '../../lib/job-queue/index.js'
import { Hooks } from '../../lib/plugins/hooks.js'
@ -224,9 +224,9 @@ async function listAccountVideos (req: express.Request, res: express.Response) {
const apiOptions = await Hooks.wrapObject({
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower,
nsfw: buildNSFWFilter(res, query.nsfw),
accountId: account.id,
user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
countVideos

View file

@ -1,11 +1,11 @@
import express from 'express'
import memoizee from 'memoizee'
import { CategoryOverview, ChannelOverview, TagOverview, VideosOverview } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoModel } from '@server/models/video/video.js'
import { CategoryOverview, ChannelOverview, TagOverview, VideosOverview } from '@peertube/peertube-models'
import { buildNSFWFilter } from '../../helpers/express-utils.js'
import express from 'express'
import memoizee from 'memoizee'
import { buildNSFWFilters } from '../../helpers/express-utils.js'
import { MEMOIZE_TTL, OVERVIEWS } from '../../initializers/constants.js'
import { apiRateLimiter, asyncMiddleware, optionalAuthenticate, videosOverviewValidator } from '../../middlewares/index.js'
import { TagModel } from '../../models/video/tag.js'
@ -14,11 +14,7 @@ const overviewsRouter = express.Router()
overviewsRouter.use(apiRateLimiter)
overviewsRouter.get('/videos',
videosOverviewValidator,
optionalAuthenticate,
asyncMiddleware(getVideosOverview)
)
overviewsRouter.get('/videos', videosOverviewValidator, optionalAuthenticate, asyncMiddleware(getVideosOverview))
// ---------------------------------------------------------------------------
@ -115,6 +111,8 @@ async function getVideos (
const serverActor = await getServerActor()
const query = await Hooks.wrapObject({
...buildNSFWFilters({ res }),
start: 0,
count: 12,
sort: '-createdAt',
@ -122,7 +120,6 @@ async function getVideos (
actorId: serverActor.id,
orLocalVideos: true
},
nsfw: buildNSFWFilter(res),
user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
countVideos: false,

View file

@ -1,4 +1,4 @@
import express from 'express'
import { HttpStatusCode, ResultList, Video, VideosSearchQueryAfterSanitize } from '@peertube/peertube-models'
import { sanitizeUrl } from '@server/helpers/core-utils.js'
import { pickSearchVideoQuery } from '@server/helpers/query.js'
import { doJSONRequest } from '@server/helpers/requests.js'
@ -9,8 +9,8 @@ import { getOrCreateAPVideo } from '@server/lib/activitypub/videos/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search.js'
import { getServerActor } from '@server/models/application/application.js'
import { HttpStatusCode, ResultList, Video, VideosSearchQueryAfterSanitize } from '@peertube/peertube-models'
import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils.js'
import express from 'express'
import { buildNSFWFilters, getCountVideos, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import {
@ -31,7 +31,8 @@ import { searchLocalUrl } from './shared/index.js'
const searchVideosRouter = express.Router()
searchVideosRouter.get('/videos',
searchVideosRouter.get(
'/videos',
openapiOperationDoc({ operationId: 'searchVideos' }),
paginationValidator,
setDefaultPagination,
@ -104,21 +105,25 @@ async function searchVideosIndex (query: VideosSearchQueryAfterSanitize, res: ex
async function searchVideosDB (query: VideosSearchQueryAfterSanitize, req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const apiOptions = await Hooks.wrapObject({
...query,
const apiOptions = await Hooks.wrapObject(
{
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower: {
actorId: serverActor.id,
orLocalVideos: true
displayOnlyForFollower: {
actorId: serverActor.id,
orLocalVideos: true
},
countVideos: getCountVideos(req),
user: res.locals.oauth
? res.locals.oauth.token.User
: undefined
},
countVideos: getCountVideos(req),
nsfw: buildNSFWFilter(res, query.nsfw),
user: res.locals.oauth
? res.locals.oauth.token.User
: undefined
}, 'filter:api.search.videos.local.list.params', { req, res })
'filter:api.search.videos.local.list.params',
{ req, res }
)
const resultList = await Hooks.wrapPromiseFun(
VideoModel.searchAndPopulateAccountAndServer.bind(VideoModel),

View file

@ -95,13 +95,33 @@ meRouter.get(
asyncMiddleware(listUserVideos)
)
meRouter.get('/me/videos/:videoId/rating', authenticate, asyncMiddleware(usersVideoRatingValidator), asyncMiddleware(getUserVideoRating))
meRouter.get(
'/me/videos/:videoId/rating',
authenticate,
asyncMiddleware(usersVideoRatingValidator),
asyncMiddleware(getUserVideoRating)
)
meRouter.put('/me', authenticate, asyncMiddleware(usersUpdateMeValidator), asyncRetryTransactionMiddleware(updateMe))
meRouter.put(
'/me',
authenticate,
asyncMiddleware(usersUpdateMeValidator),
asyncRetryTransactionMiddleware(updateMe)
)
meRouter.post('/me/avatar/pick', authenticate, reqAvatarFile, updateAvatarValidator, asyncRetryTransactionMiddleware(updateMyAvatar))
meRouter.post(
'/me/avatar/pick',
authenticate,
reqAvatarFile,
updateAvatarValidator,
asyncRetryTransactionMiddleware(updateMyAvatar)
)
meRouter.delete('/me/avatar', authenticate, asyncRetryTransactionMiddleware(deleteMyAvatar))
meRouter.delete(
'/me/avatar',
authenticate,
asyncRetryTransactionMiddleware(deleteMyAvatar)
)
// ---------------------------------------------------------------------------
@ -248,6 +268,10 @@ async function updateMe (req: express.Request, res: express.Response) {
const keysToUpdate: (keyof UserUpdateMe & keyof AttributesOnly<UserModel>)[] = [
'password',
'nsfwPolicy',
'nsfwFlagsDisplayed',
'nsfwFlagsHidden',
'nsfwFlagsWarned',
'nsfwFlagsBlurred',
'p2pEnabled',
'autoPlayVideo',
'autoPlayNextVideo',

View file

@ -1,12 +1,12 @@
import 'multer'
import express from 'express'
import { HttpStatusCode } from '@peertube/peertube-models'
import { handlesToNameAndHost } from '@server/helpers/actors.js'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { sendUndoFollow } from '@server/lib/activitypub/send/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils.js'
import express from 'express'
import 'multer'
import { buildNSFWFilters, getCountVideos } from '../../../helpers/express-utils.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { JobQueue } from '../../../lib/job-queue/index.js'
@ -34,7 +34,8 @@ import { VideoModel } from '../../../models/video/video.js'
const mySubscriptionsRouter = express.Router()
mySubscriptionsRouter.get('/me/subscriptions/videos',
mySubscriptionsRouter.get(
'/me/subscriptions/videos',
authenticate,
paginationValidator,
videosSortValidator,
@ -44,13 +45,10 @@ mySubscriptionsRouter.get('/me/subscriptions/videos',
asyncMiddleware(getUserSubscriptionVideos)
)
mySubscriptionsRouter.get('/me/subscriptions/exist',
authenticate,
areSubscriptionsExistValidator,
asyncMiddleware(areSubscriptionsExist)
)
mySubscriptionsRouter.get('/me/subscriptions/exist', authenticate, areSubscriptionsExistValidator, asyncMiddleware(areSubscriptionsExist))
mySubscriptionsRouter.get('/me/subscriptions',
mySubscriptionsRouter.get(
'/me/subscriptions',
authenticate,
paginationValidator,
userSubscriptionsSortValidator,
@ -60,19 +58,12 @@ mySubscriptionsRouter.get('/me/subscriptions',
asyncMiddleware(listUserSubscriptions)
)
mySubscriptionsRouter.post('/me/subscriptions',
authenticate,
userSubscriptionAddValidator,
addUserSubscription
)
mySubscriptionsRouter.post('/me/subscriptions', authenticate, userSubscriptionAddValidator, addUserSubscription)
mySubscriptionsRouter.get('/me/subscriptions/:uri',
authenticate,
userSubscriptionGetValidator,
asyncMiddleware(getUserSubscription)
)
mySubscriptionsRouter.get('/me/subscriptions/:uri', authenticate, userSubscriptionGetValidator, asyncMiddleware(getUserSubscription))
mySubscriptionsRouter.delete('/me/subscriptions/:uri',
mySubscriptionsRouter.delete(
'/me/subscriptions/:uri',
authenticate,
userSubscriptionGetValidator,
asyncRetryTransactionMiddleware(deleteUserSubscription)
@ -94,7 +85,7 @@ async function areSubscriptionsExist (req: express.Request, res: express.Respons
const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, sanitizedHandles)
const existObject: { [id: string ]: boolean } = {}
const existObject: { [id: string]: boolean } = {}
for (const sanitizedHandle of sanitizedHandles) {
const obj = results.find(r => {
const server = r.ActorFollowing.Server
@ -147,8 +138,8 @@ async function deleteUserSubscription (req: express.Request, res: express.Respon
})
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
.status(HttpStatusCode.NO_CONTENT_204)
.end()
}
async function listUserSubscriptions (req: express.Request, res: express.Response) {
@ -173,12 +164,12 @@ async function getUserSubscriptionVideos (req: express.Request, res: express.Res
const apiOptions = await Hooks.wrapObject({
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower: {
actorId: user.Account.Actor.id,
orLocalVideos: false
},
nsfw: buildNSFWFilter(res, query.nsfw),
user,
countVideos
}, 'filter:api.user.me.subscription-videos.list.params')

View file

@ -13,7 +13,7 @@ import { MChannelBannerAccountDefault } from '@server/types/models/index.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger.js'
import { resetSequelizeInstance } from '../../helpers/database-utils.js'
import { buildNSFWFilter, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils.js'
import { buildNSFWFilters, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils.js'
import { logger } from '../../helpers/logger.js'
import { getFormattedObjects } from '../../helpers/utils.js'
import { MIMETYPES } from '../../initializers/constants.js'
@ -395,9 +395,9 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon
const apiOptions = await Hooks.wrapObject({
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower,
nsfw: buildNSFWFilter(res, query.nsfw),
videoChannelId: videoChannelInstance.id,
user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
countVideos

View file

@ -4,7 +4,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { getServerActor } from '@server/models/application/application.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js'
import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils.js'
import { buildNSFWFilters, getCountVideos } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants.js'
@ -73,24 +73,13 @@ videosRouter.use('/', storyboardRouter)
videosRouter.use('/', videoSourceRouter)
videosRouter.use('/', videoChaptersRouter)
videosRouter.get('/categories',
openapiOperationDoc({ operationId: 'getCategories' }),
listVideoCategories
)
videosRouter.get('/licences',
openapiOperationDoc({ operationId: 'getLicences' }),
listVideoLicences
)
videosRouter.get('/languages',
openapiOperationDoc({ operationId: 'getLanguages' }),
listVideoLanguages
)
videosRouter.get('/privacies',
openapiOperationDoc({ operationId: 'getPrivacies' }),
listVideoPrivacies
)
videosRouter.get('/categories', openapiOperationDoc({ operationId: 'getCategories' }), listVideoCategories)
videosRouter.get('/licences', openapiOperationDoc({ operationId: 'getLicences' }), listVideoLicences)
videosRouter.get('/languages', openapiOperationDoc({ operationId: 'getLanguages' }), listVideoLanguages)
videosRouter.get('/privacies', openapiOperationDoc({ operationId: 'getPrivacies' }), listVideoPrivacies)
videosRouter.get('/',
videosRouter.get(
'/',
openapiOperationDoc({ operationId: 'getVideos' }),
paginationValidator,
videosSortValidator,
@ -101,7 +90,8 @@ videosRouter.get('/',
asyncMiddleware(listVideos)
)
videosRouter.get('/:id',
videosRouter.get(
'/:id',
openapiOperationDoc({ operationId: 'getVideo' }),
optionalAuthenticate,
asyncMiddleware(videosCustomGetValidator('for-api')),
@ -109,7 +99,8 @@ videosRouter.get('/:id',
asyncMiddleware(getVideo)
)
videosRouter.delete('/:id',
videosRouter.delete(
'/:id',
openapiOperationDoc({ operationId: 'delVideo' }),
authenticate,
asyncMiddleware(videosRemoveValidator),
@ -163,12 +154,12 @@ async function listVideos (req: express.Request, res: express.Response) {
const apiOptions = await Hooks.wrapObject({
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower: {
actorId: serverActor.id,
orLocalVideos: true
},
nsfw: buildNSFWFilter(res, query.nsfw),
user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
countVideos
}, 'filter:api.videos.list.params')
@ -195,6 +186,6 @@ async function removeVideo (req: express.Request, res: express.Response) {
Hooks.runAction('action:api.video.deleted', { video: videoInstance, req, res })
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
.status(HttpStatusCode.NO_CONTENT_204)
.end()
}

View file

@ -1,5 +1,13 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, ThumbnailType, VideoCommentPolicy, VideoPrivacy, VideoPrivacyType, VideoUpdate } from '@peertube/peertube-models'
import {
HttpStatusCode,
NSFWFlag,
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'
@ -35,7 +43,8 @@ const updateRouter = express.Router()
const reqVideoFileUpdate = createReqFiles([ 'thumbnailfile', 'previewfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
updateRouter.put('/:id',
updateRouter.put(
'/:id',
openapiOperationDoc({ operationId: 'putVideo' }),
authenticate,
reqVideoFileUpdate,
@ -54,7 +63,7 @@ export {
async function updateVideo (req: express.Request, res: express.Response) {
const videoFromReq = res.locals.videoAll
const oldVideoAuditView = new VideoAuditView(videoFromReq.toFormattedDetailsJSON())
const videoInfoToUpdate: VideoUpdate = req.body
const body: VideoUpdate = req.body
const hadPrivacyForFederation = isPrivacyForFederation(videoFromReq.privacy)
const oldPrivacy = videoFromReq.privacy
@ -77,6 +86,8 @@ async function updateVideo (req: express.Request, res: express.Response) {
'licence',
'language',
'nsfw',
'nsfwFlags',
'nsfwSummary',
'waitTranscoding',
'support',
'description',
@ -84,31 +95,36 @@ async function updateVideo (req: express.Request, res: express.Response) {
]
for (const key of keysToUpdate) {
if (videoInfoToUpdate[key] !== undefined) video.set(key, videoInfoToUpdate[key])
if (body[key] !== undefined) video.set(key, body[key])
}
if (!video.nsfw) {
video.nsfwFlags = NSFWFlag.NONE
video.nsfwSummary = null
}
// Special treatment for comments policy to support deprecated commentsEnabled attribute
if (videoInfoToUpdate.commentsPolicy !== undefined) {
video.commentsPolicy = videoInfoToUpdate.commentsPolicy
} else if (videoInfoToUpdate.commentsEnabled === true) {
if (body.commentsPolicy !== undefined) {
video.commentsPolicy = body.commentsPolicy
} else if (body.commentsEnabled === true) {
video.commentsPolicy = VideoCommentPolicy.ENABLED
} else if (videoInfoToUpdate.commentsEnabled === false) {
} else if (body.commentsEnabled === false) {
video.commentsPolicy = VideoCommentPolicy.DISABLED
}
if (videoInfoToUpdate.originallyPublishedAt !== undefined) {
video.originallyPublishedAt = videoInfoToUpdate.originallyPublishedAt
? new Date(videoInfoToUpdate.originallyPublishedAt)
if (body.originallyPublishedAt !== undefined) {
video.originallyPublishedAt = body.originallyPublishedAt
? new Date(body.originallyPublishedAt)
: null
}
// Privacy update?
let isNewVideoForFederation = false
if (videoInfoToUpdate.privacy !== undefined) {
if (body.privacy !== undefined) {
isNewVideoForFederation = await updateVideoPrivacy({
videoInstance: video,
videoInfoToUpdate,
videoInfoToUpdate: body,
hadPrivacyForFederation,
transaction: t
})
@ -127,8 +143,8 @@ async function updateVideo (req: express.Request, res: express.Response) {
}
// Video tags update?
if (videoInfoToUpdate.tags !== undefined) {
await setVideoTags({ video: videoInstanceUpdated, tags: videoInfoToUpdate.tags, transaction: t })
if (body.tags !== undefined) {
await setVideoTags({ video: videoInstanceUpdated, tags: body.tags, transaction: t })
}
// Video channel update?
@ -142,7 +158,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
}
// Schedule an update in the future?
await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
await updateSchedule(videoInstanceUpdated, body, t)
if (oldDescription !== video.description) {
await replaceChaptersFromDescriptionIfNeeded({
@ -181,7 +197,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
await addVideoJobsAfterUpdate({
video: videoInstanceUpdated,
nameChanged: !!videoInfoToUpdate.name,
nameChanged: !!body.name,
oldPrivacy,
isNewVideoForFederation
})
@ -196,8 +212,8 @@ async function updateVideo (req: express.Request, res: express.Response) {
}
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
.status(HttpStatusCode.NO_CONTENT_204)
.end()
}
// Return a boolean indicating if the video is considered as "new" for remote instances in the federation