Browse Source

Check video rights before providing AP information

Chocobozzz 1 week ago
parent
commit
afb28272f5
31 changed files with 247 additions and 226 deletions
  1. 9 2
      packages/tests/src/api/moderation/video-blacklist.ts
  2. 9 9
      server/core/controllers/activitypub/client.ts
  3. 1 1
      server/core/controllers/api/videos/chapters.ts
  4. 4 3
      server/core/controllers/api/videos/ownership.ts
  5. 1 1
      server/core/controllers/api/videos/token.ts
  6. 10 9
      server/core/controllers/api/videos/update.ts
  7. 6 29
      server/core/helpers/video.ts
  8. 1 1
      server/core/lib/activitypub/playlists/create-update.ts
  9. 6 1
      server/core/lib/activitypub/process/process-create.ts
  10. 10 4
      server/core/lib/activitypub/process/process-dislike.ts
  11. 8 2
      server/core/lib/activitypub/process/process-like.ts
  12. 6 1
      server/core/lib/activitypub/process/process-update.ts
  13. 1 1
      server/core/lib/activitypub/process/process-view.ts
  14. 25 23
      server/core/lib/activitypub/send/send-create.ts
  15. 10 16
      server/core/lib/activitypub/send/send-update.ts
  16. 29 42
      server/core/lib/activitypub/share.ts
  17. 3 3
      server/core/lib/activitypub/video-comments.ts
  18. 47 23
      server/core/lib/activitypub/videos/federate.ts
  19. 17 8
      server/core/lib/activitypub/videos/get.ts
  20. 19 21
      server/core/lib/model-loaders/video.ts
  21. 3 2
      server/core/lib/schedulers/update-videos-scheduler.ts
  22. 1 1
      server/core/middlewares/validators/metrics.ts
  23. 1 1
      server/core/middlewares/validators/redundancy.ts
  24. 2 2
      server/core/middlewares/validators/shared/videos.ts
  25. 1 1
      server/core/middlewares/validators/videos/video-captions.ts
  26. 2 2
      server/core/middlewares/validators/videos/video-comments.ts
  27. 1 1
      server/core/middlewares/validators/videos/video-playlists.ts
  28. 1 1
      server/core/middlewares/validators/videos/video-view.ts
  29. 2 2
      server/core/middlewares/validators/videos/videos.ts
  30. 7 13
      server/core/models/video/video.ts
  31. 4 0
      server/core/types/models/video/video.ts

+ 9 - 2
packages/tests/src/api/moderation/video-blacklist.ts

@@ -3,12 +3,12 @@
 import { expect } from 'chai'
 import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
 import { sortObjectComparator } from '@peertube/peertube-core-utils'
-import { UserAdminFlag, UserRole, VideoBlacklist, VideoBlacklistType } from '@peertube/peertube-models'
+import { HttpStatusCode, UserAdminFlag, UserRole, VideoBlacklist, VideoBlacklistType } from '@peertube/peertube-models'
 import {
   BlacklistCommand,
   cleanupTests,
   createMultipleServers,
-  doubleFollow, PeerTubeServer,
+  doubleFollow, makeActivityPubGetRequest, PeerTubeServer,
   setAccessTokensToServers,
   setDefaultChannelAvatar,
   waitJobs
@@ -298,6 +298,13 @@ describe('Test video blacklist', function () {
       expect(video4Blacklisted.unfederated).to.be.true
     })
 
+    it('Should not have AP comments/announces/likes/dislikes', async function () {
+      await makeActivityPubGetRequest(servers[0].url, `/videos/watch/${video3UUID}/comments`, HttpStatusCode.UNAUTHORIZED_401)
+      await makeActivityPubGetRequest(servers[0].url, `/videos/watch/${video3UUID}/announces`, HttpStatusCode.UNAUTHORIZED_401)
+      await makeActivityPubGetRequest(servers[0].url, `/videos/watch/${video3UUID}/likes`, HttpStatusCode.UNAUTHORIZED_401)
+      await makeActivityPubGetRequest(servers[0].url, `/videos/watch/${video3UUID}/dislikes`, HttpStatusCode.UNAUTHORIZED_401)
+    })
+
     it('Should remove the video from blacklist and refederate the video', async function () {
       await command.remove({ videoId: video4UUID })
 

+ 9 - 9
server/core/controllers/activitypub/client.ts

@@ -120,7 +120,7 @@ activityPubClientRouter.get('/videos/watch/:id/activity',
 activityPubClientRouter.get('/videos/watch/:id/announces',
   executeIfActivityPub,
   activityPubRateLimiter,
-  asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
+  asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
   asyncMiddleware(videoAnnouncesController)
 )
 activityPubClientRouter.get('/videos/watch/:id/announces/:actorId',
@@ -132,19 +132,19 @@ activityPubClientRouter.get('/videos/watch/:id/announces/:actorId',
 activityPubClientRouter.get('/videos/watch/:id/likes',
   executeIfActivityPub,
   activityPubRateLimiter,
-  asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
+  asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
   asyncMiddleware(videoLikesController)
 )
 activityPubClientRouter.get('/videos/watch/:id/dislikes',
   executeIfActivityPub,
   activityPubRateLimiter,
-  asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
+  asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
   asyncMiddleware(videoDislikesController)
 )
 activityPubClientRouter.get('/videos/watch/:id/comments',
   executeIfActivityPub,
   activityPubRateLimiter,
-  asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
+  asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
   asyncMiddleware(videoCommentsController)
 )
 activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId',
@@ -175,7 +175,7 @@ activityPubClientRouter.get('/videos/watch/:id/chapters',
   activityPubRateLimiter,
   apVideoChaptersSetCacheKey,
   chaptersCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
-  asyncMiddleware(videosCustomGetValidator('only-video')),
+  asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
   asyncMiddleware(videoChaptersController)
 )
 
@@ -330,7 +330,7 @@ async function videoAnnounceController (req: express.Request, res: express.Respo
 }
 
 async function videoAnnouncesController (req: express.Request, res: express.Response) {
-  const video = res.locals.onlyImmutableVideo
+  const video = res.locals.onlyVideo
 
   if (redirectIfNotOwned(video.url, res)) return
 
@@ -347,7 +347,7 @@ async function videoAnnouncesController (req: express.Request, res: express.Resp
 }
 
 async function videoLikesController (req: express.Request, res: express.Response) {
-  const video = res.locals.onlyImmutableVideo
+  const video = res.locals.onlyVideo
 
   if (redirectIfNotOwned(video.url, res)) return
 
@@ -357,7 +357,7 @@ async function videoLikesController (req: express.Request, res: express.Response
 }
 
 async function videoDislikesController (req: express.Request, res: express.Response) {
-  const video = res.locals.onlyImmutableVideo
+  const video = res.locals.onlyVideo
 
   if (redirectIfNotOwned(video.url, res)) return
 
@@ -367,7 +367,7 @@ async function videoDislikesController (req: express.Request, res: express.Respo
 }
 
 async function videoCommentsController (req: express.Request, res: express.Response) {
-  const video = res.locals.onlyImmutableVideo
+  const video = res.locals.onlyVideo
 
   if (redirectIfNotOwned(video.url, res)) return
 

+ 1 - 1
server/core/controllers/api/videos/chapters.ts

@@ -11,7 +11,7 @@ import { replaceChapters } from '@server/lib/video-chapters.js'
 const videoChaptersRouter = express.Router()
 
 videoChaptersRouter.get('/:id/chapters',
-  asyncMiddleware(videosCustomGetValidator('only-video')),
+  asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
   asyncMiddleware(listVideoChapters)
 )
 

+ 4 - 3
server/core/controllers/api/videos/ownership.ts

@@ -1,6 +1,7 @@
-import express from 'express'
-import { HttpStatusCode, VideoChangeOwnershipStatus, VideoState } from '@peertube/peertube-models'
+import { HttpStatusCode, VideoChangeOwnershipStatus } from '@peertube/peertube-models'
+import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js'
 import { MVideoFullLight } from '@server/types/models/index.js'
+import express from 'express'
 import { logger } from '../../../helpers/logger.js'
 import { getFormattedObjects } from '../../../helpers/utils.js'
 import { sequelizeTypescript } from '../../../initializers/database.js'
@@ -113,7 +114,7 @@ function acceptOwnership (req: express.Request, res: express.Response) {
     const targetVideoUpdated = await targetVideo.save({ transaction: t }) as MVideoFullLight
     targetVideoUpdated.VideoChannel = channel
 
-    if (targetVideoUpdated.hasPrivacyForFederation() && targetVideoUpdated.state === VideoState.PUBLISHED) {
+    if (canVideoBeFederated(targetVideoUpdated)) {
       await changeVideoChannelShare(targetVideoUpdated, oldVideoChannel, t)
       await sendUpdateVideo(targetVideoUpdated, t, oldVideoChannel.Account.Actor)
     }

+ 1 - 1
server/core/controllers/api/videos/token.ts

@@ -7,7 +7,7 @@ const tokenRouter = express.Router()
 
 tokenRouter.post('/:id/token',
   optionalAuthenticate,
-  asyncMiddleware(videosCustomGetValidator('only-video')),
+  asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
   videoFileTokenValidator,
   generateToken
 )

+ 10 - 9
server/core/controllers/api/videos/update.ts

@@ -1,9 +1,11 @@
-import express, { UploadFiles } from 'express'
-import { Transaction } from 'sequelize'
 import { forceNumber } from '@peertube/peertube-core-utils'
 import { HttpStatusCode, ThumbnailType, 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 { updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js'
+import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
+import { addVideoJobsAfterUpdate } from '@server/lib/video-jobs.js'
 import { VideoPathManager } from '@server/lib/video-path-manager.js'
 import { setVideoPrivacy } from '@server/lib/video-privacy.js'
 import { setVideoTags } from '@server/lib/video.js'
@@ -11,7 +13,9 @@ import { openapiOperationDoc } from '@server/middlewares/doc.js'
 import { VideoPasswordModel } from '@server/models/video/video-password.js'
 import { FilteredModelAttributes } from '@server/types/index.js'
 import { MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
-import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js'
+import express, { UploadFiles } from 'express'
+import { Transaction } from 'sequelize'
+import { VideoAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../../helpers/audit-logger.js'
 import { resetSequelizeInstance } from '../../../helpers/database-utils.js'
 import { createReqFiles } from '../../../helpers/express-utils.js'
 import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
@@ -22,9 +26,6 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist.js'
 import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
 import { VideoModel } from '../../../models/video/video.js'
-import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
-import { addVideoJobsAfterUpdate } from '@server/lib/video-jobs.js'
-import { updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js'
 
 const lTags = loggerTagsFactory('api', 'video')
 const auditLogger = auditLoggerFactory('videos')
@@ -53,7 +54,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
   const oldVideoAuditView = new VideoAuditView(videoFromReq.toFormattedDetailsJSON())
   const videoInfoToUpdate: VideoUpdate = req.body
 
-  const hadPrivacyForFederation = videoFromReq.hasPrivacyForFederation()
+  const hadPrivacyForFederation = isPrivacyForFederation(videoFromReq.privacy)
   const oldPrivacy = videoFromReq.privacy
 
   const thumbnails = await buildVideoThumbnailsFromReq(videoFromReq, req.files)
@@ -191,7 +192,7 @@ async function updateVideoPrivacy (options: {
   transaction: Transaction
 }) {
   const { videoInstance, videoInfoToUpdate, hadPrivacyForFederation, transaction } = options
-  const isNewVideoForFederation = videoInstance.isNewVideoForFederation(videoInfoToUpdate.privacy)
+  const isNewVideoForFederation = isNewVideoPrivacyForFederation(videoInfoToUpdate.privacy, videoInfoToUpdate.privacy)
 
   const newPrivacy = forceNumber(videoInfoToUpdate.privacy) as VideoPrivacyType
   setVideoPrivacy(videoInstance, newPrivacy)
@@ -207,7 +208,7 @@ async function updateVideoPrivacy (options: {
   }
 
   // Unfederate the video if the new privacy is not compatible with federation
-  if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
+  if (hadPrivacyForFederation && !isPrivacyForFederation(videoInstance.privacy)) {
     await VideoModel.sendDelete(videoInstance, { transaction })
   }
 

+ 6 - 29
server/core/helpers/video.ts

@@ -1,51 +1,28 @@
-import { Response } from 'express'
-import { forceNumber } from '@peertube/peertube-core-utils'
-import { VideoPrivacy, VideoPrivacyType, VideoState, VideoStateType } from '@peertube/peertube-models'
+import { VideoPrivacy } from '@peertube/peertube-models'
 import { CONFIG } from '@server/initializers/config.js'
 import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo } from '@server/types/models/index.js'
+import { Response } from 'express'
 
-function getVideoWithAttributes (res: Response) {
+export function getVideoWithAttributes (res: Response) {
   return res.locals.videoAPI || res.locals.videoAll || res.locals.onlyVideo
 }
 
-function extractVideo (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) {
+export function extractVideo (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) {
   return isStreamingPlaylist(videoOrPlaylist)
     ? videoOrPlaylist.Video
     : videoOrPlaylist
 }
 
-function isPrivacyForFederation (privacy: VideoPrivacyType) {
-  const castedPrivacy = forceNumber(privacy)
-
-  return castedPrivacy === VideoPrivacy.PUBLIC ||
-    (CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true && castedPrivacy === VideoPrivacy.UNLISTED)
-}
-
-function isStateForFederation (state: VideoStateType) {
-  const castedState = forceNumber(state)
-
-  return castedState === VideoState.PUBLISHED || castedState === VideoState.WAITING_FOR_LIVE || castedState === VideoState.LIVE_ENDED
-}
-
-function getPrivaciesForFederation () {
+export function getPrivaciesForFederation () {
   return (CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true)
     ? [ { privacy: VideoPrivacy.PUBLIC }, { privacy: VideoPrivacy.UNLISTED } ]
     : [ { privacy: VideoPrivacy.PUBLIC } ]
 }
 
-function getExtFromMimetype (mimeTypes: { [id: string]: string | string[] }, mimeType: string) {
+export function getExtFromMimetype (mimeTypes: { [id: string]: string | string[] }, mimeType: string) {
   const value = mimeTypes[mimeType]
 
   if (Array.isArray(value)) return value[0]
 
   return value
 }
-
-export {
-  getVideoWithAttributes,
-  extractVideo,
-  getExtFromMimetype,
-  isStateForFederation,
-  isPrivacyForFederation,
-  getPrivaciesForFederation
-}

+ 1 - 1
server/core/lib/activitypub/playlists/create-update.ts

@@ -145,7 +145,7 @@ async function buildElementsDBAttributes (elementUrls: string[], playlist: MVide
     try {
       const { elementObject } = await fetchRemotePlaylistElement(elementUrl)
 
-      const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video' })
+      const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video-and-blacklist' })
 
       elementsToCreate.push(playlistElementObjectToDBAttributes(elementObject, playlist, video))
     } catch (err) {

+ 6 - 1
server/core/lib/activitypub/process/process-create.ts

@@ -24,7 +24,7 @@ import { createOrUpdateLocalVideoViewer } from '../local-video-viewer.js'
 import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
 import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
 import { resolveThread } from '../video-comments.js'
-import { getOrCreateAPVideo } from '../videos/index.js'
+import { canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js'
 
 async function processCreateActivity (options: APProcessorOptions<ActivityCreate<ActivityCreateObject>>) {
   const { activity, byActor } = options
@@ -87,6 +87,11 @@ async function processCreateCacheFile (
 
   const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object })
 
+  if (video.isOwned() && !canVideoBeFederated(video)) {
+    logger.warn(`Do not process create cache file ${cacheFile.object} on a video that cannot be federated`)
+    return
+  }
+
   await sequelizeTypescript.transaction(async t => {
     return createOrUpdateCacheFile(cacheFile, video, byActor, t)
   })

+ 10 - 4
server/core/lib/activitypub/process/process-dislike.ts

@@ -1,11 +1,12 @@
-import { VideoModel } from '@server/models/video/video.js'
 import { ActivityDislike } from '@peertube/peertube-models'
+import { logger } from '@server/helpers/logger.js'
+import { VideoModel } from '@server/models/video/video.js'
 import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
 import { sequelizeTypescript } from '../../../initializers/database.js'
 import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
 import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
 import { MActorSignature } from '../../../types/models/index.js'
-import { federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
+import { canVideoBeFederated, federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
 
 async function processDislikeActivity (options: APProcessorOptions<ActivityDislike>) {
   const { activity, byActor } = options
@@ -21,14 +22,19 @@ export {
 // ---------------------------------------------------------------------------
 
 async function processDislike (activity: ActivityDislike, byActor: MActorSignature) {
-  const dislikeObject = activity.object
+  const videoUrl = activity.object
   const byAccount = byActor.Account
 
   if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
 
-  const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: dislikeObject, fetchType: 'only-video' })
+  const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video-and-blacklist' })
   if (!onlyVideo?.isOwned()) return
 
+  if (!canVideoBeFederated(onlyVideo)) {
+    logger.warn(`Do not process dislike on video ${videoUrl} that cannot be federated`)
+    return
+  }
+
   return sequelizeTypescript.transaction(async t => {
     const video = await VideoModel.loadFull(onlyVideo.id, t)
 

+ 8 - 2
server/core/lib/activitypub/process/process-like.ts

@@ -1,4 +1,5 @@
 import { ActivityLike } from '@peertube/peertube-models'
+import { logger } from '@server/helpers/logger.js'
 import { VideoModel } from '@server/models/video/video.js'
 import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
 import { sequelizeTypescript } from '../../../initializers/database.js'
@@ -6,7 +7,7 @@ import { getAPId } from '../../../lib/activitypub/activity.js'
 import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
 import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
 import { MActorSignature } from '../../../types/models/index.js'
-import { federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
+import { canVideoBeFederated, federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
 
 async function processLikeActivity (options: APProcessorOptions<ActivityLike>) {
   const { activity, byActor } = options
@@ -28,9 +29,14 @@ async function processLikeVideo (byActor: MActorSignature, activity: ActivityLik
   const byAccount = byActor.Account
   if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
 
-  const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video' })
+  const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video-and-blacklist' })
   if (!onlyVideo?.isOwned()) return
 
+  if (!canVideoBeFederated(onlyVideo)) {
+    logger.warn(`Do not process like on video ${videoUrl} that cannot be federated`)
+    return
+  }
+
   return sequelizeTypescript.transaction(async t => {
     const video = await VideoModel.loadFull(onlyVideo.id, t)
 

+ 6 - 1
server/core/lib/activitypub/process/process-update.ts

@@ -20,7 +20,7 @@ import { APActorUpdater } from '../actors/updater.js'
 import { createOrUpdateCacheFile } from '../cache-file.js'
 import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
 import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
-import { APVideoUpdater, getOrCreateAPVideo } from '../videos/index.js'
+import { APVideoUpdater, canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js'
 
 async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate<ActivityUpdateObject>>) {
   const { activity, byActor } = options
@@ -93,6 +93,11 @@ async function processUpdateCacheFile (
 
   const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
 
+  if (video.isOwned() && !canVideoBeFederated(video)) {
+    logger.warn(`Do not process update cache file on video ${activity.object} that cannot be federated`)
+    return
+  }
+
   await sequelizeTypescript.transaction(async t => {
     await createOrUpdateCacheFile(cacheFileObject, video, byActor, t)
   })

+ 1 - 1
server/core/lib/activitypub/process/process-view.ts

@@ -24,7 +24,7 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu
 
   const { video } = await getOrCreateAPVideo({
     videoObject,
-    fetchType: 'only-video',
+    fetchType: 'only-video-and-blacklist',
     allowRefresh: false
   })
 

+ 25 - 23
server/core/lib/activitypub/send/send-create.ts

@@ -1,5 +1,3 @@
-import { Transaction } from 'sequelize'
-import { getServerActor } from '@server/models/application/application.js'
 import {
   ActivityAudience,
   ActivityCreate,
@@ -9,19 +7,24 @@ import {
   VideoPlaylistPrivacy,
   VideoPrivacy
 } from '@peertube/peertube-models'
+import { AccountModel } from '@server/models/account/account.js'
+import { getServerActor } from '@server/models/application/application.js'
+import { VideoModel } from '@server/models/video/video.js'
+import { Transaction } from 'sequelize'
 import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
 import { VideoCommentModel } from '../../../models/video/video-comment.js'
 import {
   MActorLight,
   MCommentOwnerVideo,
   MLocalVideoViewerWithWatchSections,
-  MVideoAccountLight,
   MVideoAP,
+  MVideoAccountLight,
   MVideoPlaylistFull,
   MVideoRedundancyFileVideo,
   MVideoRedundancyStreamingPlaylistVideo
 } from '../../../types/models/index.js'
 import { audiencify, getAudience } from '../audience.js'
+import { canVideoBeFederated } from '../videos/federate.js'
 import {
   broadcastToActors,
   broadcastToFollowers,
@@ -32,12 +35,11 @@ import {
   sendVideoRelatedActivity,
   unicastTo
 } from './shared/index.js'
-import { AccountModel } from '@server/models/account/account.js'
 
 const lTags = loggerTagsFactory('ap', 'create')
 
-async function sendCreateVideo (video: MVideoAP, transaction: Transaction) {
-  if (!video.hasPrivacyForFederation()) return undefined
+export async function sendCreateVideo (video: MVideoAP, transaction: Transaction) {
+  if (!canVideoBeFederated(video)) return undefined
 
   logger.info('Creating job to send video creation of %s.', video.url, lTags(video.uuid))
 
@@ -56,7 +58,7 @@ async function sendCreateVideo (video: MVideoAP, transaction: Transaction) {
   })
 }
 
-async function sendCreateCacheFile (
+export async function sendCreateCacheFile (
   byActor: MActorLight,
   video: MVideoAccountLight,
   fileRedundancy: MVideoRedundancyStreamingPlaylistVideo | MVideoRedundancyFileVideo
@@ -72,7 +74,7 @@ async function sendCreateCacheFile (
   })
 }
 
-async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections, transaction: Transaction) {
+export async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections, transaction: Transaction) {
   logger.info('Creating job to send create watch action %s.', stats.url, lTags(stats.uuid))
 
   const byActor = await getServerActor()
@@ -84,7 +86,7 @@ async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections,
   return sendVideoActivityToOrigin(activityBuilder, { byActor, video: stats.Video, transaction, contextType: 'WatchAction' })
 }
 
-async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) {
+export async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) {
   if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
 
   logger.info('Creating job to send create video playlist of %s.', playlist.url, lTags(playlist.uuid))
@@ -109,11 +111,20 @@ async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transactio
   })
 }
 
-async function sendCreateVideoComment (comment: MCommentOwnerVideo, transaction: Transaction) {
-  logger.info('Creating job to send comment %s.', comment.url)
-
+export async function sendCreateVideoComment (comment: MCommentOwnerVideo, transaction: Transaction) {
   const isOrigin = comment.Video.isOwned()
 
+  if (isOrigin) {
+    const videoWithBlacklist = await VideoModel.loadWithBlacklist(comment.Video.id)
+
+    if (!canVideoBeFederated(videoWithBlacklist)) {
+      logger.debug(`Do not send comment ${comment.url} on a video that cannot be federated`)
+      return undefined
+    }
+  }
+
+  logger.info('Creating job to send comment %s.', comment.url)
+
   const byActor = comment.Account.Actor
   const videoAccount = await AccountModel.load(comment.Video.VideoChannel.Account.id, transaction)
 
@@ -179,7 +190,7 @@ async function sendCreateVideoComment (comment: MCommentOwnerVideo, transaction:
   })
 }
 
-function buildCreateActivity <T extends ActivityCreateObject> (
+export function buildCreateActivity <T extends ActivityCreateObject> (
   url: string,
   byActor: MActorLight,
   object: T,
@@ -201,16 +212,7 @@ function buildCreateActivity <T extends ActivityCreateObject> (
 }
 
 // ---------------------------------------------------------------------------
-
-export {
-  sendCreateVideo,
-  buildCreateActivity,
-  sendCreateVideoComment,
-  sendCreateVideoPlaylist,
-  sendCreateCacheFile,
-  sendCreateWatchAction
-}
-
+// Private
 // ---------------------------------------------------------------------------
 
 async function sendVideoRelatedCreateActivity (options: {

+ 10 - 16
server/core/lib/activitypub/send/send-update.ts

@@ -1,10 +1,10 @@
-import { Transaction } from 'sequelize'
-import { getServerActor } from '@server/models/application/application.js'
 import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models'
+import { getServerActor } from '@server/models/application/application.js'
+import { Transaction } from 'sequelize'
 import { logger } from '../../../helpers/logger.js'
 import { AccountModel } from '../../../models/account/account.js'
-import { VideoModel } from '../../../models/video/video.js'
 import { VideoShareModel } from '../../../models/video/video-share.js'
+import { VideoModel } from '../../../models/video/video.js'
 import {
   MAccountDefault,
   MActor,
@@ -16,11 +16,12 @@ import {
 } from '../../../types/models/index.js'
 import { audiencify, getAudience } from '../audience.js'
 import { getUpdateActivityPubUrl } from '../url.js'
+import { canVideoBeFederated } from '../videos/federate.js'
 import { getActorsInvolvedInVideo } from './shared/index.js'
 import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils.js'
 
-async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transaction, overriddenByActor?: MActor) {
-  if (!videoArg.hasPrivacyForFederation()) return undefined
+export async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transaction, overriddenByActor?: MActor) {
+  if (!canVideoBeFederated(videoArg)) return undefined
 
   const video = await videoArg.lightAPToFullAP(transaction)
 
@@ -47,7 +48,7 @@ async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transactio
   })
 }
 
-async function sendUpdateActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction: Transaction) {
+export async function sendUpdateActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction: Transaction) {
   const byActor = accountOrChannel.Actor
 
   logger.info('Creating job to update actor %s.', byActor.url)
@@ -77,7 +78,7 @@ async function sendUpdateActor (accountOrChannel: MChannelDefault | MAccountDefa
   })
 }
 
-async function sendUpdateCacheFile (byActor: MActorLight, redundancyModel: MVideoRedundancyVideo) {
+export async function sendUpdateCacheFile (byActor: MActorLight, redundancyModel: MVideoRedundancyVideo) {
   logger.info('Creating job to update cache file %s.', redundancyModel.url)
 
   const associatedVideo = redundancyModel.getVideo()
@@ -98,7 +99,7 @@ async function sendUpdateCacheFile (byActor: MActorLight, redundancyModel: MVide
   return sendVideoRelatedActivity(activityBuilder, { byActor, video, contextType: 'CacheFile' })
 }
 
-async function sendUpdateVideoPlaylist (videoPlaylist: MVideoPlaylistFull, transaction: Transaction) {
+export async function sendUpdateVideoPlaylist (videoPlaylist: MVideoPlaylistFull, transaction: Transaction) {
   if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
 
   const byActor = videoPlaylist.OwnerAccount.Actor
@@ -127,14 +128,7 @@ async function sendUpdateVideoPlaylist (videoPlaylist: MVideoPlaylistFull, trans
 }
 
 // ---------------------------------------------------------------------------
-
-export {
-  sendUpdateActor,
-  sendUpdateVideo,
-  sendUpdateCacheFile,
-  sendUpdateVideoPlaylist
-}
-
+// Private
 // ---------------------------------------------------------------------------
 
 function buildUpdateActivity (

+ 29 - 42
server/core/lib/activitypub/share.ts

@@ -1,6 +1,6 @@
+import { getServerActor } from '@server/models/application/application.js'
 import Bluebird from 'bluebird'
 import { Transaction } from 'sequelize'
-import { getServerActor } from '@server/models/application/application.js'
 import { logger, loggerTagsFactory } from '../../helpers/logger.js'
 import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants.js'
 import { VideoShareModel } from '../../models/video/video-share.js'
@@ -12,16 +12,7 @@ import { checkUrlsSameHost, getLocalVideoAnnounceActivityPubUrl } from './url.js
 
 const lTags = loggerTagsFactory('share')
 
-async function shareVideoByServerAndChannel (video: MVideoAccountLight, t: Transaction) {
-  if (!video.hasPrivacyForFederation()) return undefined
-
-  return Promise.all([
-    shareByServer(video, t),
-    shareByVideoChannel(video, t)
-  ])
-}
-
-async function changeVideoChannelShare (
+export async function changeVideoChannelShare (
   video: MVideoAccountLight,
   oldVideoChannel: MChannelActorLight,
   t: Transaction
@@ -36,7 +27,7 @@ async function changeVideoChannelShare (
   await shareByVideoChannel(video, t)
 }
 
-async function addVideoShares (shareUrls: string[], video: MVideoId) {
+export async function addVideoShares (shareUrls: string[], video: MVideoId) {
   await Bluebird.map(shareUrls, async shareUrl => {
     try {
       await addVideoShare(shareUrl, video)
@@ -46,35 +37,7 @@ async function addVideoShares (shareUrls: string[], video: MVideoId) {
   }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
 }
 
-export {
-  changeVideoChannelShare,
-  addVideoShares,
-  shareVideoByServerAndChannel
-}
-
-// ---------------------------------------------------------------------------
-
-async function addVideoShare (shareUrl: string, video: MVideoId) {
-  const { body } = await fetchAP<any>(shareUrl)
-  if (!body?.actor) throw new Error('Body or body actor is invalid')
-
-  const actorUrl = getAPId(body.actor)
-  if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
-    throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
-  }
-
-  const actor = await getOrCreateAPActor(actorUrl)
-
-  const entry = {
-    actorId: actor.id,
-    videoId: video.id,
-    url: shareUrl
-  }
-
-  await VideoShareModel.upsert(entry)
-}
-
-async function shareByServer (video: MVideo, t: Transaction) {
+export async function shareByServer (video: MVideo, t: Transaction) {
   const serverActor = await getServerActor()
 
   const serverShareUrl = getLocalVideoAnnounceActivityPubUrl(serverActor, video)
@@ -93,7 +56,7 @@ async function shareByServer (video: MVideo, t: Transaction) {
   return sendVideoAnnounce(serverActor, serverShare, video, t)
 }
 
-async function shareByVideoChannel (video: MVideoAccountLight, t: Transaction) {
+export async function shareByVideoChannel (video: MVideoAccountLight, t: Transaction) {
   const videoChannelShareUrl = getLocalVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video)
   const [ videoChannelShare ] = await VideoShareModel.findOrCreate({
     defaults: {
@@ -110,6 +73,30 @@ async function shareByVideoChannel (video: MVideoAccountLight, t: Transaction) {
   return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
 }
 
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+async function addVideoShare (shareUrl: string, video: MVideoId) {
+  const { body } = await fetchAP<any>(shareUrl)
+  if (!body?.actor) throw new Error('Body or body actor is invalid')
+
+  const actorUrl = getAPId(body.actor)
+  if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
+    throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
+  }
+
+  const actor = await getOrCreateAPActor(actorUrl)
+
+  const entry = {
+    actorId: actor.id,
+    videoId: video.id,
+    url: shareUrl
+  }
+
+  await VideoShareModel.upsert(entry)
+}
+
 async function undoShareByVideoChannel (video: MVideo, oldVideoChannel: MChannelActorLight, t: Transaction) {
   // Load old share
   const oldShare = await VideoShareModel.load(oldVideoChannel.actorId, video.id, t)

+ 3 - 3
server/core/lib/activitypub/video-comments.ts

@@ -9,7 +9,7 @@ import { Hooks } from '../plugins/hooks.js'
 import { fetchAP } from './activity.js'
 import { getOrCreateAPActor } from './actors/index.js'
 import { checkUrlsSameHost } from './url.js'
-import { getOrCreateAPVideo } from './videos/index.js'
+import { canVideoBeFederated, getOrCreateAPVideo } from './videos/index.js'
 
 type ResolveThreadParams = {
   url: string
@@ -92,8 +92,8 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
   const syncParam = { rates: true, shares: true, comments: false, refreshVideo: false }
   const { video } = await getOrCreateAPVideo({ videoObject: url, syncParam })
 
-  if (video.isOwned() && !video.hasPrivacyForFederation()) {
-    throw new Error('Cannot resolve thread of video with privacy that is not compatible with federation')
+  if (video.isOwned() && !canVideoBeFederated(video)) {
+    throw new Error('Cannot resolve thread of video that is not compatible with federation')
   }
 
   let resultComment: MCommentOwnerVideo

+ 47 - 23
server/core/lib/activitypub/videos/federate.ts

@@ -1,29 +1,53 @@
+import { forceNumber } from '@peertube/peertube-core-utils'
+import { VideoPrivacy, VideoPrivacyType, VideoState, VideoStateType } from '@peertube/peertube-models'
+import { CONFIG } from '@server/initializers/config.js'
+import { MVideoAPLight, MVideoWithBlacklistRights } from '@server/types/models/index.js'
 import { Transaction } from 'sequelize'
-import { MVideoAP, MVideoAPLight } from '@server/types/models/index.js'
 import { sendCreateVideo, sendUpdateVideo } from '../send/index.js'
-import { shareVideoByServerAndChannel } from '../share.js'
-
-async function federateVideoIfNeeded (videoArg: MVideoAPLight, isNewVideo: boolean, transaction?: Transaction) {
-  const video = videoArg as MVideoAP
-
-  if (
-    // Check this is not a blacklisted video, or unfederated blacklisted video
-    (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) &&
-    // Check the video is public/unlisted and published
-    video.hasPrivacyForFederation() && video.hasStateForFederation()
-  ) {
-    const video = await videoArg.lightAPToFullAP(transaction)
-
-    if (isNewVideo) {
-      // Now we'll add the video's meta data to our followers
-      await sendCreateVideo(video, transaction)
-      await shareVideoByServerAndChannel(video, transaction)
-    } else {
-      await sendUpdateVideo(video, transaction)
-    }
+import { shareByServer, shareByVideoChannel } from '../share.js'
+
+export async function federateVideoIfNeeded (videoArg: MVideoAPLight, isNewVideo: boolean, transaction?: Transaction) {
+  if (!canVideoBeFederated(videoArg, isNewVideo)) return
+
+  const video = await videoArg.lightAPToFullAP(transaction)
+
+  if (isNewVideo) {
+    // Now we'll add the video's meta data to our followers
+    await sendCreateVideo(video, transaction)
+
+    await Promise.all([
+      shareByServer(video, transaction),
+      shareByVideoChannel(video, transaction)
+    ])
+  } else {
+    await sendUpdateVideo(video, transaction)
+  }
+}
+
+export function canVideoBeFederated (video: MVideoWithBlacklistRights, isNewVideo = false) {
+  // Check this is not a blacklisted video
+  if (video.isBlacklisted() === true) {
+    if (isNewVideo === false) return false
+    if (video.VideoBlacklist.unfederated === true) return false
   }
+
+  // Check the video is public/unlisted and published
+  return isPrivacyForFederation(video.privacy) && isStateForFederation(video.state)
+}
+
+export function isNewVideoPrivacyForFederation (currentPrivacy: VideoPrivacyType, newPrivacy: VideoPrivacyType) {
+  return !isPrivacyForFederation(currentPrivacy) && isPrivacyForFederation(newPrivacy)
 }
 
-export {
-  federateVideoIfNeeded
+export function isPrivacyForFederation (privacy: VideoPrivacyType) {
+  const castedPrivacy = forceNumber(privacy)
+
+  return castedPrivacy === VideoPrivacy.PUBLIC ||
+    (CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true && castedPrivacy === VideoPrivacy.UNLISTED)
+}
+
+export function isStateForFederation (state: VideoStateType) {
+  const castedState = forceNumber(state)
+
+  return castedState === VideoState.PUBLISHED || castedState === VideoState.WAITING_FOR_LIVE || castedState === VideoState.LIVE_ENDED
 }

+ 17 - 8
server/core/lib/activitypub/videos/get.ts

@@ -1,9 +1,14 @@
+import { APObjectId } from '@peertube/peertube-models'
 import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
 import { logger } from '@server/helpers/logger.js'
 import { JobQueue } from '@server/lib/job-queue/index.js'
 import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders/index.js'
-import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models/index.js'
-import { APObjectId } from '@peertube/peertube-models'
+import {
+  MVideoAccountLightBlacklistAllFiles,
+  MVideoImmutable,
+  MVideoThumbnail,
+  MVideoThumbnailBlacklist
+} from '@server/types/models/index.js'
 import { getAPId } from '../activity.js'
 import { refreshVideoIfNeeded } from './refresh.js'
 import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared/index.js'
@@ -24,23 +29,25 @@ type GetVideoParamAll = {
 type GetVideoParamImmutable = {
   videoObject: APObjectId
   syncParam?: SyncParam
-  fetchType: 'only-immutable-attributes'
+  fetchType: 'unsafe-only-immutable-attributes'
   allowRefresh: false
 }
 
 type GetVideoParamOther = {
   videoObject: APObjectId
   syncParam?: SyncParam
-  fetchType?: 'all' | 'only-video'
+  fetchType?: 'all' | 'only-video-and-blacklist'
   allowRefresh?: boolean
 }
 
 export function getOrCreateAPVideo (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
 export function getOrCreateAPVideo (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
-export function getOrCreateAPVideo (options: GetVideoParamOther): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail>
+export function getOrCreateAPVideo (
+  options: GetVideoParamOther
+): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist>
 export async function getOrCreateAPVideo (
   options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther
-): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
+): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist | MVideoImmutable> {
   // Default params
   const syncParam = options.syncParam || { rates: true, shares: true, comments: true, refreshVideo: false }
   const fetchType = options.fetchType || 'all'
@@ -52,7 +59,7 @@ export async function getOrCreateAPVideo (
 
   if (videoFromDatabase) {
     if (allowRefresh === true) {
-      // Typings ensure allowRefresh === false in only-immutable-attributes fetch type
+      // Typings ensure allowRefresh === false in unsafe-only-immutable-attributes fetch type
       videoFromDatabase = await scheduleRefresh(videoFromDatabase as MVideoThumbnail, fetchType, syncParam)
     }
 
@@ -87,7 +94,9 @@ export async function getOrCreateAPVideo (
 
 export function maybeGetOrCreateAPVideo (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
 export function maybeGetOrCreateAPVideo (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
-export function maybeGetOrCreateAPVideo (options: GetVideoParamOther): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail>
+export function maybeGetOrCreateAPVideo (
+  options: GetVideoParamOther
+): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist>
 export async function maybeGetOrCreateAPVideo (options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther) {
   try {
     const result = await getOrCreateAPVideo(options as any)

+ 19 - 21
server/core/lib/model-loaders/video.ts

@@ -6,57 +6,57 @@ import {
   MVideoFullLight,
   MVideoId,
   MVideoImmutable,
-  MVideoThumbnail
+  MVideoThumbnailBlacklist
 } from '@server/types/models/index.js'
 import { getOrCreateAPVideo } from '../activitypub/videos/get.js'
 
-type VideoLoadType = 'for-api' | 'all' | 'only-video' | 'id' | 'none' | 'only-immutable-attributes'
+type VideoLoadType = 'for-api' | 'all' | 'only-video-and-blacklist' | 'id' | 'none' | 'unsafe-only-immutable-attributes'
 
 function loadVideo (id: number | string, fetchType: 'for-api', userId?: number): Promise<MVideoFormattableDetails>
 function loadVideo (id: number | string, fetchType: 'all', userId?: number): Promise<MVideoFullLight>
-function loadVideo (id: number | string, fetchType: 'only-immutable-attributes'): Promise<MVideoImmutable>
-function loadVideo (id: number | string, fetchType: 'only-video', userId?: number): Promise<MVideoThumbnail>
+function loadVideo (id: number | string, fetchType: 'unsafe-only-immutable-attributes'): Promise<MVideoImmutable>
+function loadVideo (id: number | string, fetchType: 'only-video-and-blacklist', userId?: number): Promise<MVideoThumbnailBlacklist>
 function loadVideo (id: number | string, fetchType: 'id' | 'none', userId?: number): Promise<MVideoId>
 function loadVideo (
   id: number | string,
   fetchType: VideoLoadType,
   userId?: number
-): Promise<MVideoFullLight | MVideoThumbnail | MVideoId | MVideoImmutable>
+): Promise<MVideoFullLight | MVideoThumbnailBlacklist | MVideoId | MVideoImmutable>
 function loadVideo (
   id: number | string,
   fetchType: VideoLoadType,
   userId?: number
-): Promise<MVideoFullLight | MVideoThumbnail | MVideoId | MVideoImmutable> {
+): Promise<MVideoFullLight | MVideoThumbnailBlacklist | MVideoId | MVideoImmutable> {
 
   if (fetchType === 'for-api') return VideoModel.loadForGetAPI({ id, userId })
 
   if (fetchType === 'all') return VideoModel.loadFull(id, undefined, userId)
 
-  if (fetchType === 'only-immutable-attributes') return VideoModel.loadImmutableAttributes(id)
+  if (fetchType === 'unsafe-only-immutable-attributes') return VideoModel.loadImmutableAttributes(id)
 
-  if (fetchType === 'only-video') return VideoModel.load(id)
+  if (fetchType === 'only-video-and-blacklist') return VideoModel.loadWithBlacklist(id)
 
   if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id)
 }
 
-type VideoLoadByUrlType = 'all' | 'only-video' | 'only-immutable-attributes'
+type VideoLoadByUrlType = 'all' | 'only-video-and-blacklist' | 'unsafe-only-immutable-attributes'
 
 function loadVideoByUrl (url: string, fetchType: 'all'): Promise<MVideoAccountLightBlacklistAllFiles>
-function loadVideoByUrl (url: string, fetchType: 'only-immutable-attributes'): Promise<MVideoImmutable>
-function loadVideoByUrl (url: string, fetchType: 'only-video'): Promise<MVideoThumbnail>
+function loadVideoByUrl (url: string, fetchType: 'unsafe-only-immutable-attributes'): Promise<MVideoImmutable>
+function loadVideoByUrl (url: string, fetchType: 'only-video-and-blacklist'): Promise<MVideoThumbnailBlacklist>
 function loadVideoByUrl (
   url: string,
   fetchType: VideoLoadByUrlType
-): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable>
+): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist | MVideoImmutable>
 function loadVideoByUrl (
   url: string,
   fetchType: VideoLoadByUrlType
-): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
+): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist | MVideoImmutable> {
   if (fetchType === 'all') return VideoModel.loadByUrlAndPopulateAccountAndFiles(url)
 
-  if (fetchType === 'only-immutable-attributes') return VideoModel.loadByUrlImmutableAttributes(url)
+  if (fetchType === 'unsafe-only-immutable-attributes') return VideoModel.loadByUrlImmutableAttributes(url)
 
-  if (fetchType === 'only-video') return VideoModel.loadByUrl(url)
+  if (fetchType === 'only-video-and-blacklist') return VideoModel.loadByUrlWithBlacklist(url)
 }
 
 async function loadOrCreateVideoIfAllowedForUser (videoUrl: string) {
@@ -64,7 +64,7 @@ async function loadOrCreateVideoIfAllowedForUser (videoUrl: string) {
     try {
       const res = await getOrCreateAPVideo({
         videoObject: videoUrl,
-        fetchType: 'only-immutable-attributes',
+        fetchType: 'unsafe-only-immutable-attributes',
         allowRefresh: false
       })
 
@@ -78,10 +78,8 @@ async function loadOrCreateVideoIfAllowedForUser (videoUrl: string) {
 }
 
 export {
-  type VideoLoadType,
-  type VideoLoadByUrlType,
-
-  loadVideo,
+  loadOrCreateVideoIfAllowedForUser, loadVideo,
   loadVideoByUrl,
-  loadOrCreateVideoIfAllowedForUser
+  type VideoLoadByUrlType,
+  type VideoLoadType
 }

+ 3 - 2
server/core/lib/schedulers/update-videos-scheduler.ts

@@ -5,10 +5,11 @@ import { logger } from '../../helpers/logger.js'
 import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants.js'
 import { sequelizeTypescript } from '../../initializers/database.js'
 import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-update.js'
+import { isNewVideoPrivacyForFederation } from '../activitypub/videos/federate.js'
 import { Notifier } from '../notifier/index.js'
+import { addVideoJobsAfterUpdate } from '../video-jobs.js'
 import { VideoPathManager } from '../video-path-manager.js'
 import { setVideoPrivacy } from '../video-privacy.js'
-import { addVideoJobsAfterUpdate } from '../video-jobs.js'
 import { AbstractScheduler } from './abstract-scheduler.js'
 
 export class UpdateVideosScheduler extends AbstractScheduler {
@@ -58,7 +59,7 @@ export class UpdateVideosScheduler extends AbstractScheduler {
       logger.info('Executing scheduled video update on %s.', video.uuid)
 
       if (schedule.privacy) {
-        isNewVideoForFederation = video.isNewVideoForFederation(schedule.privacy)
+        isNewVideoForFederation = isNewVideoPrivacyForFederation(video.privacy, schedule.privacy)
         oldPrivacy = video.privacy
 
         setVideoPrivacy(video, schedule.privacy)

+ 1 - 1
server/core/middlewares/validators/metrics.ts

@@ -47,7 +47,7 @@ const addPlaybackMetricValidator = [
     const body: PlaybackMetricCreate = req.body
 
     if (areValidationErrors(req, res)) return
-    if (!await doesVideoExist(body.videoId, res, 'only-immutable-attributes')) return
+    if (!await doesVideoExist(body.videoId, res, 'unsafe-only-immutable-attributes')) return
 
     return next()
   }

+ 1 - 1
server/core/middlewares/validators/redundancy.ts

@@ -143,7 +143,7 @@ const addVideoRedundancyValidator = [
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res)) return
 
-    if (!await doesVideoExist(req.body.videoId, res, 'only-video')) return
+    if (!await doesVideoExist(req.body.videoId, res, 'only-video-and-blacklist')) return
 
     if (res.locals.onlyVideo.remote === false) {
       return res.fail({ message: 'Cannot create a redundancy on a local video' })

+ 2 - 2
server/core/middlewares/validators/shared/videos.ts

@@ -47,7 +47,7 @@ export async function doesVideoExist (id: number | string, res: Response, fetchT
       res.locals.videoAll = video as MVideoFullLight
       break
 
-    case 'only-immutable-attributes':
+    case 'unsafe-only-immutable-attributes':
       res.locals.onlyImmutableVideo = video as MVideoImmutable
       break
 
@@ -55,7 +55,7 @@ export async function doesVideoExist (id: number | string, res: Response, fetchT
       res.locals.videoId = video as MVideoId
       break
 
-    case 'only-video':
+    case 'only-video-and-blacklist':
       res.locals.onlyVideo = video as MVideoThumbnail
       break
   }

+ 1 - 1
server/core/middlewares/validators/videos/video-captions.ts

@@ -67,7 +67,7 @@ const listVideoCaptionsValidator = [
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res)) return
-    if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
+    if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
 
     const video = res.locals.onlyVideo
     if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.videoId })) return

+ 2 - 2
server/core/middlewares/validators/videos/video-comments.ts

@@ -56,7 +56,7 @@ const listVideoCommentThreadsValidator = [
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res)) return
-    if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
+    if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
 
     if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.onlyVideo })) return
 
@@ -73,7 +73,7 @@ const listVideoThreadCommentsValidator = [
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res)) return
-    if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
+    if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
     if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return
 
     if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.onlyVideo })) return

+ 1 - 1
server/core/middlewares/validators/videos/video-playlists.ts

@@ -205,7 +205,7 @@ const videoPlaylistsAddVideoValidator = [
     if (areValidationErrors(req, res)) return
 
     if (!await doesVideoPlaylistExist(req.params.playlistId, res, 'all')) return
-    if (!await doesVideoExist(req.body.videoId, res, 'only-video')) return
+    if (!await doesVideoExist(req.body.videoId, res, 'only-video-and-blacklist')) return
 
     const videoPlaylist = getPlaylist(res)
 

+ 1 - 1
server/core/middlewares/validators/videos/video-view.ts

@@ -37,7 +37,7 @@ export const videoViewValidator = [
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res)) return
-    if (!await doesVideoExist(req.params.videoId, res, 'only-immutable-attributes')) return
+    if (!await doesVideoExist(req.params.videoId, res, 'unsafe-only-immutable-attributes')) return
 
     const video = res.locals.onlyImmutableVideo
     const { duration } = await getCachedVideoDuration(video.id)

+ 2 - 2
server/core/middlewares/validators/videos/videos.ts

@@ -243,7 +243,7 @@ async function checkVideoFollowConstraints (req: express.Request, res: express.R
   })
 }
 
-const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes') => {
+const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video-and-blacklist' | 'unsafe-only-immutable-attributes') => {
   return [
     isValidVideoIdParam('id'),
 
@@ -254,7 +254,7 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' |
       if (!await doesVideoExist(req.params.id, res, fetchType)) return
 
       // Controllers does not need to check video rights
-      if (fetchType === 'only-immutable-attributes') return next()
+      if (fetchType === 'unsafe-only-immutable-attributes') return next()
 
       const video = getVideoWithAttributes(res) as MVideoFullLight
 

+ 7 - 13
server/core/models/video/video.ts

@@ -19,7 +19,7 @@ import {
   type VideoStateType
 } from '@peertube/peertube-models'
 import { uuidToShort } from '@peertube/peertube-node-utils'
-import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video.js'
+import { getPrivaciesForFederation } from '@server/helpers/video.js'
 import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
 import { LiveManager } from '@server/lib/live/live-manager.js'
 import {
@@ -1448,6 +1448,12 @@ export class VideoModel extends SequelizeModel<VideoModel> {
     return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
   }
 
+  static loadByUrlWithBlacklist (url: string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
+    const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
+
+    return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails-blacklist' })
+  }
+
   static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLight> {
     const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
 
@@ -2045,18 +2051,6 @@ export class VideoModel extends SequelizeModel<VideoModel> {
     return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
   }
 
-  hasPrivacyForFederation () {
-    return isPrivacyForFederation(this.privacy)
-  }
-
-  hasStateForFederation () {
-    return isStateForFederation(this.state)
-  }
-
-  isNewVideoForFederation (newPrivacy: VideoPrivacyType) {
-    return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
-  }
-
   setAsRefreshed (transaction?: Transaction) {
     return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
   }

+ 4 - 0
server/core/types/models/video/video.ts

@@ -102,6 +102,10 @@ export type MVideoWithBlacklistLight =
   MVideo &
   Use<'VideoBlacklist', MVideoBlacklistLight>
 
+export type MVideoWithBlacklistRights =
+  MVideo &
+  Use<'VideoBlacklist', MVideoBlacklistUnfederated>
+
 export type MVideoAccountLight =
   MVideo &
   Use<'VideoChannel', MChannelAccountLight>