Browse Source

Add banners support

Chocobozzz 3 years ago
parent
commit
2cb03dc1f4
33 changed files with 390 additions and 238 deletions
  1. 2 2
      server/controllers/api/config.ts
  2. 4 4
      server/controllers/api/users/me.ts
  3. 7 5
      server/controllers/api/users/my-subscriptions.ts
  4. 49 17
      server/controllers/api/video-channel.ts
  5. 1 1
      server/controllers/api/videos/ownership.ts
  6. 1 1
      server/controllers/lazy-static.ts
  7. 2 2
      server/controllers/static.ts
  8. 4 4
      server/helpers/custom-validators/users.ts
  9. 3 4
      server/helpers/middlewares/video-channels.ts
  10. 11 12
      server/helpers/middlewares/videos.ts
  11. 14 6
      server/initializers/constants.ts
  12. 79 27
      server/lib/activitypub/actor.ts
  13. 2 4
      server/lib/activitypub/process/process-delete.ts
  14. 9 5
      server/lib/activitypub/process/process-update.ts
  15. 31 20
      server/lib/actor-image.ts
  16. 3 3
      server/lib/client-html.ts
  17. 1 1
      server/lib/emailer.ts
  18. 4 12
      server/lib/video-channel.ts
  19. 10 6
      server/middlewares/validators/avatar.ts
  20. 0 1
      server/middlewares/validators/follows.ts
  21. 0 2
      server/middlewares/validators/videos/video-channels.ts
  22. 0 7
      server/models/activitypub/actor-follow.ts
  23. 42 5
      server/models/activitypub/actor.ts
  24. 45 43
      server/models/video/video-channel.ts
  25. 5 5
      server/types/models/account/account.ts
  26. 3 8
      server/types/models/account/actor-follow.ts
  27. 30 3
      server/types/models/account/actor.ts
  28. 4 4
      server/types/models/user/user.ts
  29. 15 19
      server/types/models/video/video-channels.ts
  30. 4 3
      server/typings/express/index.d.ts
  31. 2 1
      shared/models/activitypub/activitypub-actor.ts
  32. 1 1
      shared/models/activitypub/objects/common-objects.ts
  33. 2 0
      shared/models/videos/channel/video-channel.model.ts

+ 2 - 2
server/controllers/api/config.ts

@@ -158,9 +158,9 @@ async function getConfig (req: express.Request, res: express.Response) {
     avatar: {
       file: {
         size: {
-          max: CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max
+          max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
         },
-        extensions: CONSTRAINTS_FIELDS.ACTORS.AVATAR.EXTNAME
+        extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
       }
     },
     video: {

+ 4 - 4
server/controllers/api/users/me.ts

@@ -2,7 +2,7 @@ import 'multer'
 import * as express from 'express'
 import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger'
 import { Hooks } from '@server/lib/plugins/hooks'
-import { UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../../shared'
+import { ActorImageType, UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../../shared'
 import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
 import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
 import { createReqFiles } from '../../../helpers/express-utils'
@@ -11,7 +11,7 @@ import { CONFIG } from '../../../initializers/config'
 import { MIMETYPES } from '../../../initializers/constants'
 import { sequelizeTypescript } from '../../../initializers/database'
 import { sendUpdateActor } from '../../../lib/activitypub/send'
-import { deleteLocalActorAvatarFile, updateLocalActorAvatarFile } from '../../../lib/actor-image'
+import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../../lib/actor-image'
 import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
 import {
   asyncMiddleware,
@@ -238,7 +238,7 @@ async function updateMyAvatar (req: express.Request, res: express.Response) {
 
   const userAccount = await AccountModel.load(user.Account.id)
 
-  const avatar = await updateLocalActorAvatarFile(userAccount, avatarPhysicalFile)
+  const avatar = await updateLocalActorImageFile(userAccount, avatarPhysicalFile, ActorImageType.AVATAR)
 
   return res.json({ avatar: avatar.toFormattedJSON() })
 }
@@ -247,7 +247,7 @@ async function deleteMyAvatar (req: express.Request, res: express.Response) {
   const user = res.locals.oauth.token.user
 
   const userAccount = await AccountModel.load(user.Account.id)
-  await deleteLocalActorAvatarFile(userAccount)
+  await deleteLocalActorImageFile(userAccount, ActorImageType.AVATAR)
 
   return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
 }

+ 7 - 5
server/controllers/api/users/my-subscriptions.ts

@@ -1,5 +1,8 @@
 import 'multer'
 import * as express from 'express'
+import { sendUndoFollow } from '@server/lib/activitypub/send'
+import { VideoChannelModel } from '@server/models/video/video-channel'
+import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
 import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
 import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils'
 import { getFormattedObjects } from '../../../helpers/utils'
@@ -26,8 +29,6 @@ import {
 } from '../../../middlewares/validators'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { VideoModel } from '../../../models/video/video'
-import { sendUndoFollow } from '@server/lib/activitypub/send'
-import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
 
 const mySubscriptionsRouter = express.Router()
 
@@ -66,7 +67,7 @@ mySubscriptionsRouter.post('/me/subscriptions',
 mySubscriptionsRouter.get('/me/subscriptions/:uri',
   authenticate,
   userSubscriptionGetValidator,
-  getUserSubscription
+  asyncMiddleware(getUserSubscription)
 )
 
 mySubscriptionsRouter.delete('/me/subscriptions/:uri',
@@ -130,10 +131,11 @@ function addUserSubscription (req: express.Request, res: express.Response) {
   return res.status(HttpStatusCode.NO_CONTENT_204).end()
 }
 
-function getUserSubscription (req: express.Request, res: express.Response) {
+async function getUserSubscription (req: express.Request, res: express.Response) {
   const subscription = res.locals.subscription
+  const videoChannel = await VideoChannelModel.loadAndPopulateAccount(subscription.ActorFollowing.VideoChannel.id)
 
-  return res.json(subscription.ActorFollowing.VideoChannel.toFormattedJSON())
+  return res.json(videoChannel.toFormattedJSON())
 }
 
 async function deleteUserSubscription (req: express.Request, res: express.Response) {

+ 49 - 17
server/controllers/api/video-channel.ts

@@ -1,8 +1,8 @@
 import * as express from 'express'
 import { Hooks } from '@server/lib/plugins/hooks'
 import { getServerActor } from '@server/models/application/application'
-import { MChannelAccountDefault } from '@server/types/models'
-import { VideoChannelCreate, VideoChannelUpdate } from '../../../shared'
+import { MChannelBannerAccountDefault } from '@server/types/models'
+import { ActorImageType, VideoChannelCreate, VideoChannelUpdate } from '../../../shared'
 import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
 import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger'
 import { resetSequelizeInstance } from '../../helpers/database-utils'
@@ -13,7 +13,7 @@ import { CONFIG } from '../../initializers/config'
 import { MIMETYPES } from '../../initializers/constants'
 import { sequelizeTypescript } from '../../initializers/database'
 import { sendUpdateActor } from '../../lib/activitypub/send'
-import { deleteLocalActorAvatarFile, updateLocalActorAvatarFile } from '../../lib/actor-image'
+import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../lib/actor-image'
 import { JobQueue } from '../../lib/job-queue'
 import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
 import {
@@ -33,7 +33,7 @@ import {
   videoPlaylistsSortValidator
 } from '../../middlewares'
 import { videoChannelsNameWithHostValidator, videoChannelsOwnSearchValidator, videosSortValidator } from '../../middlewares/validators'
-import { updateAvatarValidator } from '../../middlewares/validators/avatar'
+import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/avatar'
 import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
 import { AccountModel } from '../../models/account/account'
 import { VideoModel } from '../../models/video/video'
@@ -42,6 +42,7 @@ import { VideoPlaylistModel } from '../../models/video/video-playlist'
 
 const auditLogger = auditLoggerFactory('channels')
 const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
+const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { bannerfile: CONFIG.STORAGE.TMP_DIR })
 
 const videoChannelRouter = express.Router()
 
@@ -69,6 +70,15 @@ videoChannelRouter.post('/:nameWithHost/avatar/pick',
   asyncMiddleware(updateVideoChannelAvatar)
 )
 
+videoChannelRouter.post('/:nameWithHost/banner/pick',
+  authenticate,
+  reqBannerFile,
+  // Check the rights
+  asyncMiddleware(videoChannelsUpdateValidator),
+  updateBannerValidator,
+  asyncMiddleware(updateVideoChannelBanner)
+)
+
 videoChannelRouter.delete('/:nameWithHost/avatar',
   authenticate,
   // Check the rights
@@ -76,6 +86,13 @@ videoChannelRouter.delete('/:nameWithHost/avatar',
   asyncMiddleware(deleteVideoChannelAvatar)
 )
 
+videoChannelRouter.delete('/:nameWithHost/banner',
+  authenticate,
+  // Check the rights
+  asyncMiddleware(videoChannelsUpdateValidator),
+  asyncMiddleware(deleteVideoChannelBanner)
+)
+
 videoChannelRouter.put('/:nameWithHost',
   authenticate,
   asyncMiddleware(videoChannelsUpdateValidator),
@@ -134,26 +151,41 @@ async function listVideoChannels (req: express.Request, res: express.Response) {
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }
 
+async function updateVideoChannelBanner (req: express.Request, res: express.Response) {
+  const bannerPhysicalFile = req.files['bannerfile'][0]
+  const videoChannel = res.locals.videoChannel
+  const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
+
+  const banner = await updateLocalActorImageFile(videoChannel, bannerPhysicalFile, ActorImageType.BANNER)
+
+  auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
+
+  return res.json({ banner: banner.toFormattedJSON() })
+}
 async function updateVideoChannelAvatar (req: express.Request, res: express.Response) {
   const avatarPhysicalFile = req.files['avatarfile'][0]
   const videoChannel = res.locals.videoChannel
   const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
 
-  const avatar = await updateLocalActorAvatarFile(videoChannel, avatarPhysicalFile)
+  const avatar = await updateLocalActorImageFile(videoChannel, avatarPhysicalFile, ActorImageType.AVATAR)
 
   auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
 
-  return res
-    .json({
-      avatar: avatar.toFormattedJSON()
-    })
-    .end()
+  return res.json({ avatar: avatar.toFormattedJSON() })
 }
 
 async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) {
   const videoChannel = res.locals.videoChannel
 
-  await deleteLocalActorAvatarFile(videoChannel)
+  await deleteLocalActorImageFile(videoChannel, ActorImageType.AVATAR)
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function deleteVideoChannelBanner (req: express.Request, res: express.Response) {
+  const videoChannel = res.locals.videoChannel
+
+  await deleteLocalActorImageFile(videoChannel, ActorImageType.BANNER)
 
   return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
 }
@@ -177,7 +209,7 @@ async function addVideoChannel (req: express.Request, res: express.Response) {
     videoChannel: {
       id: videoChannelCreated.id
     }
-  }).end()
+  })
 }
 
 async function updateVideoChannel (req: express.Request, res: express.Response) {
@@ -206,7 +238,7 @@ async function updateVideoChannel (req: express.Request, res: express.Response)
         }
       }
 
-      const videoChannelInstanceUpdated = await videoChannelInstance.save(sequelizeOptions) as MChannelAccountDefault
+      const videoChannelInstanceUpdated = await videoChannelInstance.save(sequelizeOptions) as MChannelBannerAccountDefault
       await sendUpdateActor(videoChannelInstanceUpdated, t)
 
       auditLogger.update(
@@ -252,13 +284,13 @@ async function removeVideoChannel (req: express.Request, res: express.Response)
 }
 
 async function getVideoChannel (req: express.Request, res: express.Response) {
-  const videoChannelWithVideos = await VideoChannelModel.loadAndPopulateAccountAndVideos(res.locals.videoChannel.id)
+  const videoChannel = res.locals.videoChannel
 
-  if (videoChannelWithVideos.isOutdated()) {
-    JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'actor', url: videoChannelWithVideos.Actor.url } })
+  if (videoChannel.isOutdated()) {
+    JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'actor', url: videoChannel.Actor.url } })
   }
 
-  return res.json(videoChannelWithVideos.toFormattedJSON())
+  return res.json(videoChannel.toFormattedJSON())
 }
 
 async function listVideoChannelPlaylists (req: express.Request, res: express.Response) {

+ 1 - 1
server/controllers/api/videos/ownership.ts

@@ -107,7 +107,7 @@ async function acceptOwnership (req: express.Request, res: express.Response) {
     // We need more attributes for federation
     const targetVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoChangeOwnership.Video.id)
 
-    const oldVideoChannel = await VideoChannelModel.loadByIdAndPopulateAccount(targetVideo.channelId)
+    const oldVideoChannel = await VideoChannelModel.loadAndPopulateAccount(targetVideo.channelId)
 
     targetVideo.channelId = channel.id
 

+ 1 - 1
server/controllers/lazy-static.ts

@@ -64,7 +64,7 @@ async function getActorImage (req: express.Request, res: express.Response) {
     logger.info('Lazy serve remote actor image %s.', image.fileUrl)
 
     try {
-      await pushActorImageProcessInQueue({ filename: image.filename, fileUrl: image.fileUrl })
+      await pushActorImageProcessInQueue({ filename: image.filename, fileUrl: image.fileUrl, type: image.type })
     } catch (err) {
       logger.warn('Cannot process remote actor image %s.', image.fileUrl, { err })
       return res.sendStatus(HttpStatusCode.NOT_FOUND_404)

+ 2 - 2
server/controllers/static.ts

@@ -252,9 +252,9 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
           avatar: {
             file: {
               size: {
-                max: CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max
+                max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
               },
-              extensions: CONSTRAINTS_FIELDS.ACTORS.AVATAR.EXTNAME
+              extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
             }
           },
           video: {

+ 4 - 4
server/helpers/custom-validators/users.ts

@@ -1,9 +1,9 @@
+import { values } from 'lodash'
 import validator from 'validator'
 import { UserRole } from '../../../shared'
+import { isEmailEnabled } from '../../initializers/config'
 import { CONSTRAINTS_FIELDS, NSFW_POLICY_TYPES } from '../../initializers/constants'
 import { exists, isArray, isBooleanValid, isFileValid } from './misc'
-import { values } from 'lodash'
-import { isEmailEnabled } from '../../initializers/config'
 
 const USERS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USERS
 
@@ -97,12 +97,12 @@ function isUserRoleValid (value: any) {
   return exists(value) && validator.isInt('' + value) && UserRole[value] !== undefined
 }
 
-const avatarMimeTypes = CONSTRAINTS_FIELDS.ACTORS.AVATAR.EXTNAME
+const avatarMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
   .map(v => v.replace('.', ''))
   .join('|')
 const avatarMimeTypesRegex = `image/(${avatarMimeTypes})`
 function isAvatarFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
-  return isFileValid(files, avatarMimeTypesRegex, 'avatarfile', CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max)
+  return isFileValid(files, avatarMimeTypesRegex, 'avatarfile', CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max)
 }
 
 // ---------------------------------------------------------------------------

+ 3 - 4
server/helpers/middlewares/video-channels.ts

@@ -1,7 +1,7 @@
 import * as express from 'express'
-import { VideoChannelModel } from '../../models/video/video-channel'
-import { MChannelAccountDefault } from '@server/types/models'
+import { MChannelBannerAccountDefault } from '@server/types/models'
 import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
+import { VideoChannelModel } from '../../models/video/video-channel'
 
 async function doesLocalVideoChannelNameExist (name: string, res: express.Response) {
   const videoChannel = await VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
@@ -29,11 +29,10 @@ export {
   doesVideoChannelNameWithHostExist
 }
 
-function processVideoChannelExist (videoChannel: MChannelAccountDefault, res: express.Response) {
+function processVideoChannelExist (videoChannel: MChannelBannerAccountDefault, res: express.Response) {
   if (!videoChannel) {
     res.status(HttpStatusCode.NOT_FOUND_404)
        .json({ error: 'Video channel not found' })
-       .end()
 
     return false
   }

+ 11 - 12
server/helpers/middlewares/videos.ts

@@ -66,25 +66,24 @@ async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | st
 }
 
 async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAccountId, res: Response) {
-  if (user.hasRight(UserRight.UPDATE_ANY_VIDEO) === true) {
-    const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId)
-    if (videoChannel === null) {
-      res.status(HttpStatusCode.BAD_REQUEST_400)
-         .json({ error: 'Unknown video `video channel` on this instance.' })
-         .end()
+  const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId)
 
-      return false
-    }
+  if (videoChannel === null) {
+    res.status(HttpStatusCode.BAD_REQUEST_400)
+       .json({ error: 'Unknown video "video channel" for this instance.' })
 
+    return false
+  }
+
+  // Don't check account id if the user can update any video
+  if (user.hasRight(UserRight.UPDATE_ANY_VIDEO) === true) {
     res.locals.videoChannel = videoChannel
     return true
   }
 
-  const videoChannel = await VideoChannelModel.loadByIdAndAccount(channelId, user.Account.id)
-  if (videoChannel === null) {
+  if (videoChannel.Account.id !== user.Account.id) {
     res.status(HttpStatusCode.BAD_REQUEST_400)
-       .json({ error: 'Unknown video `video channel` for this account.' })
-       .end()
+      .json({ error: 'Unknown video "video channel" for this account.' })
 
     return false
   }

+ 14 - 6
server/initializers/constants.ts

@@ -305,7 +305,7 @@ const CONSTRAINTS_FIELDS = {
     PUBLIC_KEY: { min: 10, max: 5000 }, // Length
     PRIVATE_KEY: { min: 10, max: 5000 }, // Length
     URL: { min: 3, max: 2000 }, // Length
-    AVATAR: {
+    IMAGE: {
       EXTNAME: [ '.png', '.jpeg', '.jpg', '.gif', '.webp' ],
       FILE_SIZE: {
         max: 2 * 1024 * 1024 // 2MB
@@ -466,6 +466,8 @@ const MIMETYPES = {
   IMAGE: {
     MIMETYPE_EXT: {
       'image/png': '.png',
+      'image/gif': '.gif',
+      'image/webp': '.webp',
       'image/jpg': '.jpg',
       'image/jpeg': '.jpg'
     },
@@ -605,9 +607,15 @@ const PREVIEWS_SIZE = {
   height: 480,
   minWidth: 400
 }
-const AVATARS_SIZE = {
-  width: 120,
-  height: 120
+const ACTOR_IMAGES_SIZE = {
+  AVATARS: {
+    width: 120,
+    height: 120
+  },
+  BANNERS: {
+    width: 1920,
+    height: 384
+  }
 }
 
 const EMBED_SIZE = {
@@ -755,7 +763,7 @@ if (isTestInstance() === true) {
   ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 10 * 1000 // 10 seconds
   ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 10 * 1000 // 10 seconds
 
-  CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB
+  CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max = 100 * 1024 // 100KB
   CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max = 400 * 1024 // 400KB
 
   SCHEDULER_INTERVALS_MS.actorFollowScores = 1000
@@ -816,7 +824,7 @@ export {
   SEARCH_INDEX,
   HLS_REDUNDANCY_DIRECTORY,
   P2P_MEDIA_LOADER_PEER_VERSION,
-  AVATARS_SIZE,
+  ACTOR_IMAGES_SIZE,
   ACCEPT_HEADERS,
   BCRYPT_SALT_SIZE,
   TRACKER_RATE_LIMITS,

+ 79 - 27
server/lib/activitypub/actor.ts

@@ -4,6 +4,7 @@ import { Op, Transaction } from 'sequelize'
 import { URL } from 'url'
 import { v4 as uuidv4 } from 'uuid'
 import { getServerActor } from '@server/models/application/application'
+import { ActorImageType } from '@shared/models'
 import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
 import { ActivityPubActor, ActivityPubActorType, ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
 import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects'
@@ -30,10 +31,10 @@ import {
   MActorAccountChannelId,
   MActorAccountChannelIdActor,
   MActorAccountId,
-  MActorDefault,
   MActorFull,
   MActorFullActor,
   MActorId,
+  MActorImages,
   MChannel
 } from '../../types/models'
 import { JobQueue } from '../job-queue'
@@ -168,43 +169,60 @@ async function updateActorInstance (actorInstance: ActorModel, attributes: Activ
   }
 }
 
-type AvatarInfo = { name: string, onDisk: boolean, fileUrl: string }
-async function updateActorAvatarInstance (actor: MActorDefault, info: AvatarInfo, t: Transaction) {
+type AvatarInfo = { name: string, onDisk: boolean, fileUrl: string, type: ActorImageType }
+async function updateActorImageInstance (actor: MActorImages, info: AvatarInfo, t: Transaction) {
   if (!info.name) return actor
 
-  if (actor.Avatar) {
+  const oldImageModel = info.type === ActorImageType.AVATAR
+    ? actor.Avatar
+    : actor.Banner
+
+  if (oldImageModel) {
     // Don't update the avatar if the file URL did not change
-    if (info.fileUrl && actor.Avatar.fileUrl === info.fileUrl) return actor
+    if (info.fileUrl && oldImageModel.fileUrl === info.fileUrl) return actor
 
     try {
-      await actor.Avatar.destroy({ transaction: t })
+      await oldImageModel.destroy({ transaction: t })
     } catch (err) {
-      logger.error('Cannot remove old avatar of actor %s.', actor.url, { err })
+      logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
     }
   }
 
-  const avatar = await ActorImageModel.create({
+  const imageModel = await ActorImageModel.create({
     filename: info.name,
     onDisk: info.onDisk,
-    fileUrl: info.fileUrl
+    fileUrl: info.fileUrl,
+    type: info.type
   }, { transaction: t })
 
-  actor.avatarId = avatar.id
-  actor.Avatar = avatar
+  if (info.type === ActorImageType.AVATAR) {
+    actor.avatarId = imageModel.id
+    actor.Avatar = imageModel
+  } else {
+    actor.bannerId = imageModel.id
+    actor.Banner = imageModel
+  }
 
   return actor
 }
 
-async function deleteActorAvatarInstance (actor: MActorDefault, t: Transaction) {
+async function deleteActorImageInstance (actor: MActorImages, type: ActorImageType, t: Transaction) {
   try {
-    await actor.Avatar.destroy({ transaction: t })
+    if (type === ActorImageType.AVATAR) {
+      await actor.Avatar.destroy({ transaction: t })
+
+      actor.avatarId = null
+      actor.Avatar = null
+    } else {
+      await actor.Banner.destroy({ transaction: t })
+
+      actor.bannerId = null
+      actor.Banner = null
+    }
   } catch (err) {
-    logger.error('Cannot remove old avatar of actor %s.', actor.url, { err })
+    logger.error('Cannot remove old image of actor %s.', actor.url, { err })
   }
 
-  actor.avatarId = null
-  actor.Avatar = null
-
   return actor
 }
 
@@ -219,9 +237,11 @@ async function fetchActorTotalItems (url: string) {
   }
 }
 
-function getAvatarInfoIfExists (actorJSON: ActivityPubActor) {
+function getImageInfoIfExists (actorJSON: ActivityPubActor, type: ActorImageType) {
   const mimetypes = MIMETYPES.IMAGE
-  const icon = actorJSON.icon
+  const icon = type === ActorImageType.AVATAR
+    ? actorJSON.icon
+    : actorJSON.image
 
   if (!icon || icon.type !== 'Image' || !isActivityPubUrlValid(icon.url)) return undefined
 
@@ -239,7 +259,8 @@ function getAvatarInfoIfExists (actorJSON: ActivityPubActor) {
 
   return {
     name: uuidv4() + extension,
-    fileUrl: icon.url
+    fileUrl: icon.url,
+    type
   }
 }
 
@@ -293,10 +314,22 @@ async function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannel
         const avatarInfo = {
           name: result.avatar.name,
           fileUrl: result.avatar.fileUrl,
-          onDisk: false
+          onDisk: false,
+          type: ActorImageType.AVATAR
+        }
+
+        await updateActorImageInstance(actor, avatarInfo, t)
+      }
+
+      if (result.banner !== undefined) {
+        const bannerInfo = {
+          name: result.banner.name,
+          fileUrl: result.banner.fileUrl,
+          onDisk: false,
+          type: ActorImageType.BANNER
         }
 
-        await updateActorAvatarInstance(actor, avatarInfo, t)
+        await updateActorImageInstance(actor, bannerInfo, t)
       }
 
       // Force update
@@ -338,11 +371,11 @@ export {
   buildActorInstance,
   generateAndSaveActorKeys,
   fetchActorTotalItems,
-  getAvatarInfoIfExists,
+  getImageInfoIfExists,
   updateActorInstance,
-  deleteActorAvatarInstance,
+  deleteActorImageInstance,
   refreshActorIfNeeded,
-  updateActorAvatarInstance,
+  updateActorImageInstance,
   addFetchOutboxJob
 }
 
@@ -381,12 +414,25 @@ function saveActorAndServerAndModelIfNotExist (
       const avatar = await ActorImageModel.create({
         filename: result.avatar.name,
         fileUrl: result.avatar.fileUrl,
-        onDisk: false
+        onDisk: false,
+        type: ActorImageType.AVATAR
       }, { transaction: t })
 
       actor.avatarId = avatar.id
     }
 
+    // Banner?
+    if (result.banner) {
+      const banner = await ActorImageModel.create({
+        filename: result.banner.name,
+        fileUrl: result.banner.fileUrl,
+        onDisk: false,
+        type: ActorImageType.BANNER
+      }, { transaction: t })
+
+      actor.bannerId = banner.id
+    }
+
     // Force the actor creation, sometimes Sequelize skips the save() when it thinks the instance already exists
     // (which could be false in a retried query)
     const [ actorCreated, created ] = await ActorModel.findOrCreate<MActorFullActor>({
@@ -440,6 +486,10 @@ type FetchRemoteActorResult = {
     name: string
     fileUrl: string
   }
+  banner?: {
+    name: string
+    fileUrl: string
+  }
   attributedTo: ActivityPubAttributedTo[]
 }
 async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: number, result: FetchRemoteActorResult }> {
@@ -479,7 +529,8 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
       : null
   })
 
-  const avatarInfo = await getAvatarInfoIfExists(actorJSON)
+  const avatarInfo = getImageInfoIfExists(actorJSON, ActorImageType.AVATAR)
+  const bannerInfo = getImageInfoIfExists(actorJSON, ActorImageType.BANNER)
 
   const name = actorJSON.name || actorJSON.preferredUsername
   return {
@@ -488,6 +539,7 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
       actor,
       name,
       avatar: avatarInfo,
+      banner: bannerInfo,
       summary: actorJSON.summary,
       support: actorJSON.support,
       playlists: actorJSON.playlists,

+ 2 - 4
server/lib/activitypub/process/process-delete.ts

@@ -7,7 +7,7 @@ import { VideoModel } from '../../../models/video/video'
 import { VideoCommentModel } from '../../../models/video/video-comment'
 import { VideoPlaylistModel } from '../../../models/video/video-playlist'
 import { APProcessorOptions } from '../../../types/activitypub-processor.model'
-import { MAccountActor, MActor, MActorSignature, MChannelActor, MChannelActorAccountActor, MCommentOwnerVideo } from '../../../types/models'
+import { MAccountActor, MActor, MActorSignature, MChannelActor, MCommentOwnerVideo } from '../../../types/models'
 import { markCommentAsDeleted } from '../../video-comment'
 import { forwardVideoRelatedActivity } from '../send/utils'
 
@@ -30,9 +30,7 @@ async function processDeleteActivity (options: APProcessorOptions<ActivityDelete
     } else if (byActorFull.type === 'Group') {
       if (!byActorFull.VideoChannel) throw new Error('Actor ' + byActorFull.url + ' is a group but we cannot find it in database.')
 
-      const channelToDelete = byActorFull.VideoChannel as MChannelActorAccountActor
-      channelToDelete.Actor = byActorFull
-
+      const channelToDelete = Object.assign({}, byActorFull.VideoChannel, { Actor: byActorFull })
       return retryTransactionWrapper(processDeleteVideoChannel, channelToDelete)
     }
   }

+ 9 - 5
server/lib/activitypub/process/process-update.ts

@@ -6,7 +6,7 @@ import { sequelizeTypescript } from '../../../initializers/database'
 import { AccountModel } from '../../../models/account/account'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoChannelModel } from '../../../models/video/video-channel'
-import { getAvatarInfoIfExists, updateActorAvatarInstance, updateActorInstance } from '../actor'
+import { getImageInfoIfExists, updateActorImageInstance, updateActorInstance } from '../actor'
 import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannelFromVideoObject, updateVideoFromAP } from '../videos'
 import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
 import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
@@ -17,6 +17,7 @@ import { createOrUpdateVideoPlaylist } from '../playlist'
 import { APProcessorOptions } from '../../../types/activitypub-processor.model'
 import { MActorSignature, MAccountIdActor } from '../../../types/models'
 import { isRedundancyAccepted } from '@server/lib/redundancy'
+import { ActorImageType } from '@shared/models'
 
 async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate>) {
   const { activity, byActor } = options
@@ -119,7 +120,8 @@ async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate)
   let accountOrChannelFieldsSave: object
 
   // Fetch icon?
-  const avatarInfo = await getAvatarInfoIfExists(actorAttributesToUpdate)
+  const avatarInfo = getImageInfoIfExists(actorAttributesToUpdate, ActorImageType.AVATAR)
+  const bannerInfo = getImageInfoIfExists(actorAttributesToUpdate, ActorImageType.BANNER)
 
   try {
     await sequelizeTypescript.transaction(async t => {
@@ -132,10 +134,12 @@ async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate)
 
       await updateActorInstance(actor, actorAttributesToUpdate)
 
-      if (avatarInfo !== undefined) {
-        const avatarOptions = Object.assign({}, avatarInfo, { onDisk: false })
+      for (const imageInfo of [ avatarInfo, bannerInfo ]) {
+        if (!imageInfo) continue
 
-        await updateActorAvatarInstance(actor, avatarOptions, t)
+        const imageOptions = Object.assign({}, imageInfo, { onDisk: false })
+
+        await updateActorImageInstance(actor, imageOptions, t)
       }
 
       await actor.save({ transaction: t })

+ 31 - 20
server/lib/actor-image.ts

@@ -3,50 +3,57 @@ import { queue } from 'async'
 import * as LRUCache from 'lru-cache'
 import { extname, join } from 'path'
 import { v4 as uuidv4 } from 'uuid'
+import { ActorImageType } from '@shared/models'
 import { retryTransactionWrapper } from '../helpers/database-utils'
 import { processImage } from '../helpers/image-utils'
 import { downloadImage } from '../helpers/requests'
 import { CONFIG } from '../initializers/config'
-import { AVATARS_SIZE, LRU_CACHE, QUEUE_CONCURRENCY } from '../initializers/constants'
+import { ACTOR_IMAGES_SIZE, LRU_CACHE, QUEUE_CONCURRENCY } from '../initializers/constants'
 import { sequelizeTypescript } from '../initializers/database'
 import { MAccountDefault, MChannelDefault } from '../types/models'
-import { deleteActorAvatarInstance, updateActorAvatarInstance } from './activitypub/actor'
+import { deleteActorImageInstance, updateActorImageInstance } from './activitypub/actor'
 import { sendUpdateActor } from './activitypub/send'
 
-async function updateLocalActorAvatarFile (
+async function updateLocalActorImageFile (
   accountOrChannel: MAccountDefault | MChannelDefault,
-  avatarPhysicalFile: Express.Multer.File
+  imagePhysicalFile: Express.Multer.File,
+  type: ActorImageType
 ) {
-  const extension = extname(avatarPhysicalFile.filename)
+  const imageSize = type === ActorImageType.AVATAR
+    ? ACTOR_IMAGES_SIZE.AVATARS
+    : ACTOR_IMAGES_SIZE.BANNERS
 
-  const avatarName = uuidv4() + extension
-  const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, avatarName)
-  await processImage(avatarPhysicalFile.path, destination, AVATARS_SIZE)
+  const extension = extname(imagePhysicalFile.filename)
+
+  const imageName = uuidv4() + extension
+  const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName)
+  await processImage(imagePhysicalFile.path, destination, imageSize)
 
   return retryTransactionWrapper(() => {
     return sequelizeTypescript.transaction(async t => {
-      const avatarInfo = {
-        name: avatarName,
+      const actorImageInfo = {
+        name: imageName,
         fileUrl: null,
+        type,
         onDisk: true
       }
 
-      const updatedActor = await updateActorAvatarInstance(accountOrChannel.Actor, avatarInfo, t)
+      const updatedActor = await updateActorImageInstance(accountOrChannel.Actor, actorImageInfo, t)
       await updatedActor.save({ transaction: t })
 
       await sendUpdateActor(accountOrChannel, t)
 
-      return updatedActor.Avatar
+      return type === ActorImageType.AVATAR
+        ? updatedActor.Avatar
+        : updatedActor.Banner
     })
   })
 }
 
-async function deleteLocalActorAvatarFile (
-  accountOrChannel: MAccountDefault | MChannelDefault
-) {
+async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MChannelDefault, type: ActorImageType) {
   return retryTransactionWrapper(() => {
     return sequelizeTypescript.transaction(async t => {
-      const updatedActor = await deleteActorAvatarInstance(accountOrChannel.Actor, t)
+      const updatedActor = await deleteActorImageInstance(accountOrChannel.Actor, type, t)
       await updatedActor.save({ transaction: t })
 
       await sendUpdateActor(accountOrChannel, t)
@@ -56,10 +63,14 @@ async function deleteLocalActorAvatarFile (
   })
 }
 
-type DownloadImageQueueTask = { fileUrl: string, filename: string }
+type DownloadImageQueueTask = { fileUrl: string, filename: string, type: ActorImageType }
 
 const downloadImageQueue = queue<DownloadImageQueueTask, Error>((task, cb) => {
-  downloadImage(task.fileUrl, CONFIG.STORAGE.ACTOR_IMAGES, task.filename, AVATARS_SIZE)
+  const size = task.type === ActorImageType.AVATAR
+    ? ACTOR_IMAGES_SIZE.AVATARS
+    : ACTOR_IMAGES_SIZE.BANNERS
+
+  downloadImage(task.fileUrl, CONFIG.STORAGE.ACTOR_IMAGES, task.filename, size)
     .then(() => cb())
     .catch(err => cb(err))
 }, QUEUE_CONCURRENCY.ACTOR_PROCESS_IMAGE)
@@ -79,7 +90,7 @@ const actorImagePathUnsafeCache = new LRUCache<string, string>({ max: LRU_CACHE.
 
 export {
   actorImagePathUnsafeCache,
-  updateLocalActorAvatarFile,
-  deleteLocalActorAvatarFile,
+  updateLocalActorImageFile,
+  deleteLocalActorImageFile,
   pushActorImageProcessInQueue
 }

+ 3 - 3
server/lib/client-html.ts

@@ -11,7 +11,7 @@ import { logger } from '../helpers/logger'
 import { CONFIG } from '../initializers/config'
 import {
   ACCEPT_HEADERS,
-  AVATARS_SIZE,
+  ACTOR_IMAGES_SIZE,
   CUSTOM_HTML_TAG_COMMENTS,
   EMBED_SIZE,
   FILES_CONTENT_HASH,
@@ -246,8 +246,8 @@ class ClientHtml {
 
     const image = {
       url: entity.Actor.getAvatarUrl(),
-      width: AVATARS_SIZE.width,
-      height: AVATARS_SIZE.height
+      width: ACTOR_IMAGES_SIZE.AVATARS.width,
+      height: ACTOR_IMAGES_SIZE.AVATARS.height
     }
 
     const ogType = 'website'

+ 1 - 1
server/lib/emailer.ts

@@ -405,7 +405,7 @@ class Emailer {
   async addVideoAutoBlacklistModeratorsNotification (to: string[], videoBlacklist: MVideoBlacklistLightVideo) {
     const videoAutoBlacklistUrl = WEBSERVER.URL + '/admin/moderation/video-auto-blacklist/list'
     const videoUrl = WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
-    const channel = (await VideoChannelModel.loadByIdAndPopulateAccount(videoBlacklist.Video.channelId)).toFormattedSummaryJSON()
+    const channel = (await VideoChannelModel.loadAndPopulateAccount(videoBlacklist.Video.channelId)).toFormattedSummaryJSON()
 
     const emailPayload: EmailPayload = {
       template: 'video-auto-blacklist-new',

+ 4 - 12
server/lib/video-channel.ts

@@ -3,18 +3,12 @@ import { v4 as uuidv4 } from 'uuid'
 import { VideoChannelCreate } from '../../shared/models'
 import { VideoModel } from '../models/video/video'
 import { VideoChannelModel } from '../models/video/video-channel'
-import { MAccountId, MChannelDefault, MChannelId } from '../types/models'
+import { MAccountId, MChannelId } from '../types/models'
 import { buildActorInstance } from './activitypub/actor'
 import { getLocalVideoChannelActivityPubUrl } from './activitypub/url'
 import { federateVideoIfNeeded } from './activitypub/videos'
 
-type CustomVideoChannelModelAccount <T extends MAccountId> = MChannelDefault & { Account?: T }
-
-async function createLocalVideoChannel <T extends MAccountId> (
-  videoChannelInfo: VideoChannelCreate,
-  account: T,
-  t: Sequelize.Transaction
-): Promise<CustomVideoChannelModelAccount<T>> {
+async function createLocalVideoChannel (videoChannelInfo: VideoChannelCreate, account: MAccountId, t: Sequelize.Transaction) {
   const uuid = uuidv4()
   const url = getLocalVideoChannelActivityPubUrl(videoChannelInfo.name)
   const actorInstance = buildActorInstance('Group', url, videoChannelInfo.name, uuid)
@@ -32,13 +26,11 @@ async function createLocalVideoChannel <T extends MAccountId> (
   const videoChannel = new VideoChannelModel(videoChannelData)
 
   const options = { transaction: t }
-  const videoChannelCreated: CustomVideoChannelModelAccount<T> = await videoChannel.save(options) as MChannelDefault
+  const videoChannelCreated = await videoChannel.save(options)
 
-  // Do not forget to add Account/Actor information to the created video channel
-  videoChannelCreated.Account = account
   videoChannelCreated.Actor = actorInstanceCreated
 
-  // No need to seed this empty video channel to followers
+  // No need to send this empty video channel to followers
   return videoChannelCreated
 }
 

+ 10 - 6
server/middlewares/validators/avatar.ts

@@ -6,21 +6,25 @@ import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { logger } from '../../helpers/logger'
 import { cleanUpReqFiles } from '../../helpers/express-utils'
 
-const updateAvatarValidator = [
-  body('avatarfile').custom((value, { req }) => isAvatarFile(req.files)).withMessage(
+const updateActorImageValidatorFactory = (fieldname: string) => ([
+  body(fieldname).custom((value, { req }) => isAvatarFile(req.files)).withMessage(
     'This file is not supported or too large. Please, make sure it is of the following type : ' +
-    CONSTRAINTS_FIELDS.ACTORS.AVATAR.EXTNAME.join(', ')
+    CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME.join(', ')
   ),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking updateAvatarValidator parameters', { files: req.files })
+    logger.debug('Checking updateActorImageValidator parameters', { files: req.files })
 
     if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
 
     return next()
   }
-]
+])
+
+const updateAvatarValidator = updateActorImageValidatorFactory('avatarfile')
+const updateBannerValidator = updateActorImageValidatorFactory('bannerfile')
 
 export {
-  updateAvatarValidator
+  updateAvatarValidator,
+  updateBannerValidator
 }

+ 0 - 1
server/middlewares/validators/follows.ts

@@ -68,7 +68,6 @@ const removeFollowingValidator = [
         .json({
           error: `Following ${req.params.host} not found.`
         })
-        .end()
     }
 
     res.locals.follow = follow

+ 0 - 2
server/middlewares/validators/videos/video-channels.ts

@@ -73,13 +73,11 @@ const videoChannelsUpdateValidator = [
     if (res.locals.videoChannel.Actor.isOwned() === false) {
       return res.status(HttpStatusCode.FORBIDDEN_403)
         .json({ error: 'Cannot update video channel of another server' })
-        .end()
     }
 
     if (res.locals.videoChannel.Account.userId !== res.locals.oauth.token.User.id) {
       return res.status(HttpStatusCode.FORBIDDEN_403)
         .json({ error: 'Cannot update video channel of another user' })
-        .end()
     }
 
     return next()

+ 0 - 7
server/models/activitypub/actor-follow.ts

@@ -248,13 +248,6 @@ export class ActorFollowModel extends Model {
     }
 
     return ActorFollowModel.findOne(query)
-      .then(result => {
-        if (result?.ActorFollowing.VideoChannel) {
-          result.ActorFollowing.VideoChannel.Actor = result.ActorFollowing
-        }
-
-        return result
-      })
   }
 
   static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> {

+ 42 - 5
server/models/activitypub/actor.ts

@@ -29,11 +29,19 @@ import {
   isActorPublicKeyValid
 } from '../../helpers/custom-validators/activitypub/actor'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
-import { ACTIVITY_PUB, ACTIVITY_PUB_ACTOR_TYPES, CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants'
+import {
+  ACTIVITY_PUB,
+  ACTIVITY_PUB_ACTOR_TYPES,
+  CONSTRAINTS_FIELDS,
+  MIMETYPES,
+  SERVER_ACTOR_NAME,
+  WEBSERVER
+} from '../../initializers/constants'
 import {
   MActor,
   MActorAccountChannelId,
-  MActorAP,
+  MActorAPAccount,
+  MActorAPChannel,
   MActorFormattable,
   MActorFull,
   MActorHost,
@@ -104,6 +112,11 @@ export const unusedActorAttributesForAPI = [
         model: ActorImageModel,
         as: 'Avatar',
         required: false
+      },
+      {
+        model: ActorImageModel,
+        as: 'Banner',
+        required: false
       }
     ]
   }
@@ -531,29 +544,46 @@ export class ActorModel extends Model {
   toFormattedJSON (this: MActorFormattable) {
     const base = this.toFormattedSummaryJSON()
 
+    let banner: ActorImage = null
+    if (this.bannerId) {
+      banner = this.Banner.toFormattedJSON()
+    }
+
     return Object.assign(base, {
       id: this.id,
       hostRedundancyAllowed: this.getRedundancyAllowed(),
       followingCount: this.followingCount,
       followersCount: this.followersCount,
+      banner,
       createdAt: this.createdAt,
       updatedAt: this.updatedAt
     })
   }
 
-  toActivityPubObject (this: MActorAP, name: string) {
+  toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
     let icon: ActivityIconObject
+    let image: ActivityIconObject
 
     if (this.avatarId) {
       const extension = extname(this.Avatar.filename)
 
       icon = {
         type: 'Image',
-        mediaType: extension === '.png' ? 'image/png' : 'image/jpeg',
+        mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
         url: this.getAvatarUrl()
       }
     }
 
+    if (this.bannerId) {
+      const extension = extname((this as MActorAPChannel).Banner.filename)
+
+      image = {
+        type: 'Image',
+        mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
+        url: this.getBannerUrl()
+      }
+    }
+
     const json = {
       type: this.type,
       id: this.url,
@@ -573,7 +603,8 @@ export class ActorModel extends Model {
         owner: this.url,
         publicKeyPem: this.publicKey
       },
-      icon
+      icon,
+      image
     }
 
     return activityPubContextify(json)
@@ -643,6 +674,12 @@ export class ActorModel extends Model {
     return WEBSERVER.URL + this.Avatar.getStaticPath()
   }
 
+  getBannerUrl () {
+    if (!this.bannerId) return undefined
+
+    return WEBSERVER.URL + this.Banner.getStaticPath()
+  }
+
   isOutdated () {
     if (this.isOwned()) return false
 

+ 45 - 43
server/models/video/video-channel.ts

@@ -28,10 +28,9 @@ import {
 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
 import { sendDeleteActor } from '../../lib/activitypub/send'
 import {
-  MChannelAccountDefault,
   MChannelActor,
-  MChannelActorAccountDefaultVideos,
   MChannelAP,
+  MChannelBannerAccountDefault,
   MChannelFormattable,
   MChannelSummaryFormattable
 } from '../../types/models/video'
@@ -49,6 +48,7 @@ export enum ScopeNames {
   SUMMARY = 'SUMMARY',
   WITH_ACCOUNT = 'WITH_ACCOUNT',
   WITH_ACTOR = 'WITH_ACTOR',
+  WITH_ACTOR_BANNER = 'WITH_ACTOR_BANNER',
   WITH_VIDEOS = 'WITH_VIDEOS',
   WITH_STATS = 'WITH_STATS'
 }
@@ -168,6 +168,20 @@ export type SummaryOptions = {
       ActorModel
     ]
   },
+  [ScopeNames.WITH_ACTOR_BANNER]: {
+    include: [
+      {
+        model: ActorModel,
+        include: [
+          {
+            model: ActorImageModel,
+            required: false,
+            as: 'Banner'
+          }
+        ]
+      }
+    ]
+  },
   [ScopeNames.WITH_VIDEOS]: {
     include: [
       VideoModel
@@ -442,7 +456,7 @@ export class VideoChannelModel extends Model {
       where
     }
 
-    const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ]
+    const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ]
 
     if (options.withStats === true) {
       scopes.push({
@@ -458,32 +472,13 @@ export class VideoChannelModel extends Model {
       })
   }
 
-  static loadByIdAndPopulateAccount (id: number): Promise<MChannelAccountDefault> {
-    return VideoChannelModel.unscoped()
-      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
-      .findByPk(id)
-  }
-
-  static loadByIdAndAccount (id: number, accountId: number): Promise<MChannelAccountDefault> {
-    const query = {
-      where: {
-        id,
-        accountId
-      }
-    }
-
-    return VideoChannelModel.unscoped()
-      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
-      .findOne(query)
-  }
-
-  static loadAndPopulateAccount (id: number): Promise<MChannelAccountDefault> {
+  static loadAndPopulateAccount (id: number): Promise<MChannelBannerAccountDefault> {
     return VideoChannelModel.unscoped()
-      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
       .findByPk(id)
   }
 
-  static loadByUrlAndPopulateAccount (url: string): Promise<MChannelAccountDefault> {
+  static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> {
     const query = {
       include: [
         {
@@ -491,7 +486,14 @@ export class VideoChannelModel extends Model {
           required: true,
           where: {
             url
-          }
+          },
+          include: [
+            {
+              model: ActorImageModel,
+              required: false,
+              as: 'Banner'
+            }
+          ]
         }
       ]
     }
@@ -509,7 +511,7 @@ export class VideoChannelModel extends Model {
     return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
   }
 
-  static loadLocalByNameAndPopulateAccount (name: string): Promise<MChannelAccountDefault> {
+  static loadLocalByNameAndPopulateAccount (name: string): Promise<MChannelBannerAccountDefault> {
     const query = {
       include: [
         {
@@ -518,17 +520,24 @@ export class VideoChannelModel extends Model {
           where: {
             preferredUsername: name,
             serverId: null
-          }
+          },
+          include: [
+            {
+              model: ActorImageModel,
+              required: false,
+              as: 'Banner'
+            }
+          ]
         }
       ]
     }
 
     return VideoChannelModel.unscoped()
-      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .scope([ ScopeNames.WITH_ACCOUNT ])
       .findOne(query)
   }
 
-  static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise<MChannelAccountDefault> {
+  static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise<MChannelBannerAccountDefault> {
     const query = {
       include: [
         {
@@ -542,6 +551,11 @@ export class VideoChannelModel extends Model {
               model: ServerModel,
               required: true,
               where: { host }
+            },
+            {
+              model: ActorImageModel,
+              required: false,
+              as: 'Banner'
             }
           ]
         }
@@ -549,22 +563,10 @@ export class VideoChannelModel extends Model {
     }
 
     return VideoChannelModel.unscoped()
-      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .scope([ ScopeNames.WITH_ACCOUNT ])
       .findOne(query)
   }
 
-  static loadAndPopulateAccountAndVideos (id: number): Promise<MChannelActorAccountDefaultVideos> {
-    const options = {
-      include: [
-        VideoModel
-      ]
-    }
-
-    return VideoChannelModel.unscoped()
-      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
-      .findByPk(id, options)
-  }
-
   toFormattedSummaryJSON (this: MChannelSummaryFormattable): VideoChannelSummary {
     const actor = this.Actor.toFormattedSummaryJSON()
 

+ 5 - 5
server/types/models/account/account.ts

@@ -1,7 +1,10 @@
+import { FunctionProperties, PickWith } from '@shared/core-utils'
 import { AccountModel } from '../../../models/account/account'
+import { MChannelDefault } from '../video/video-channels'
+import { MAccountBlocklistId } from './account-blocklist'
 import {
   MActor,
-  MActorAP,
+  MActorAPAccount,
   MActorAPI,
   MActorAudience,
   MActorDefault,
@@ -13,9 +16,6 @@ import {
   MActorSummaryFormattable,
   MActorUrl
 } from './actor'
-import { FunctionProperties, PickWith } from '@shared/core-utils'
-import { MAccountBlocklistId } from './account-blocklist'
-import { MChannelDefault } from '../video/video-channels'
 
 type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M>
 
@@ -106,4 +106,4 @@ export type MAccountFormattable =
 
 export type MAccountAP =
   Pick<MAccount, 'name' | 'description'> &
-  Use<'Actor', MActorAP>
+  Use<'Actor', MActorAPAccount>

+ 3 - 8
server/types/models/account/actor-follow.ts

@@ -1,16 +1,15 @@
+import { PickWith } from '@shared/core-utils'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import {
   MActor,
   MActorChannelAccountActor,
   MActorDefault,
   MActorDefaultAccountChannel,
+  MActorDefaultChannelId,
   MActorFormattable,
   MActorHost,
   MActorUsername
 } from './actor'
-import { PickWith } from '@shared/core-utils'
-import { ActorModel } from '@server/models/activitypub/actor'
-import { MChannelDefault } from '../video/video-channels'
 
 type Use<K extends keyof ActorFollowModel, M> = PickWith<ActorFollowModel, K, M>
 
@@ -47,14 +46,10 @@ export type MActorFollowFull =
 
 // For subscriptions
 
-type SubscriptionFollowing =
-  MActorDefault &
-  PickWith<ActorModel, 'VideoChannel', MChannelDefault>
-
 export type MActorFollowActorsDefaultSubscription =
   MActorFollow &
   Use<'ActorFollower', MActorDefault> &
-  Use<'ActorFollowing', SubscriptionFollowing>
+  Use<'ActorFollowing', MActorDefaultChannelId>
 
 export type MActorFollowSubscriptions =
   MActorFollow &

+ 30 - 3
server/types/models/account/actor.ts

@@ -1,3 +1,4 @@
+
 import { FunctionProperties, PickWith, PickWithOpt } from '@shared/core-utils'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { MServer, MServerHost, MServerHostBlocks, MServerRedundancyAllowed } from '../server'
@@ -6,6 +7,7 @@ import { MAccount, MAccountDefault, MAccountId, MAccountIdActor } from './accoun
 import { MActorImage, MActorImageFormattable } from './actor-image'
 
 type Use<K extends keyof ActorModel, M> = PickWith<ActorModel, K, M>
+type UseOpt<K extends keyof ActorModel, M> = PickWithOpt<ActorModel, K, M>
 
 // ############################################################################
 
@@ -75,11 +77,26 @@ export type MActorServer =
 
 // Complex actor associations
 
+export type MActorImages =
+  MActor &
+  Use<'Avatar', MActorImage> &
+  UseOpt<'Banner', MActorImage>
+
 export type MActorDefault =
   MActor &
   Use<'Server', MServer> &
   Use<'Avatar', MActorImage>
 
+export type MActorDefaultChannelId =
+  MActorDefault &
+  Use<'VideoChannel', MChannelId>
+
+export type MActorDefaultBanner =
+  MActor &
+  Use<'Server', MServer> &
+  Use<'Avatar', MActorImage> &
+  Use<'Banner', MActorImage>
+
 // Actor with channel that is associated to an account and its actor
 // Actor -> VideoChannel -> Account -> Actor
 export type MActorChannelAccountActor =
@@ -90,6 +107,7 @@ export type MActorFull =
   MActor &
   Use<'Server', MServer> &
   Use<'Avatar', MActorImage> &
+  Use<'Banner', MActorImage> &
   Use<'Account', MAccount> &
   Use<'VideoChannel', MChannelAccountActor>
 
@@ -98,6 +116,7 @@ export type MActorFullActor =
   MActor &
   Use<'Server', MServer> &
   Use<'Avatar', MActorImage> &
+  Use<'Banner', MActorImage> &
   Use<'Account', MAccountDefault> &
   Use<'VideoChannel', MChannelAccountDefault>
 
@@ -131,9 +150,17 @@ export type MActorSummaryFormattable =
 
 export type MActorFormattable =
   MActorSummaryFormattable &
-  Pick<MActor, 'id' | 'followingCount' | 'followersCount' | 'createdAt' | 'updatedAt'> &
-  Use<'Server', MServerHost & Partial<Pick<MServer, 'redundancyAllowed'>>>
+  Pick<MActor, 'id' | 'followingCount' | 'followersCount' | 'createdAt' | 'updatedAt' | 'bannerId' | 'avatarId'> &
+  Use<'Server', MServerHost & Partial<Pick<MServer, 'redundancyAllowed'>>> &
+  UseOpt<'Banner', MActorImageFormattable>
 
-export type MActorAP =
+type MActorAPBase =
   MActor &
   Use<'Avatar', MActorImage>
+
+export type MActorAPAccount =
+  MActorAPBase
+
+export type MActorAPChannel =
+  MActorAPBase &
+  Use<'Banner', MActorImage>

+ 4 - 4
server/types/models/user/user.ts

@@ -1,5 +1,7 @@
-import { UserModel } from '../../../models/account/user'
+import { AccountModel } from '@server/models/account/account'
+import { MVideoPlaylist } from '@server/types/models'
 import { PickWith, PickWithOpt } from '@shared/core-utils'
+import { UserModel } from '../../../models/account/user'
 import {
   MAccount,
   MAccountDefault,
@@ -9,10 +11,8 @@ import {
   MAccountIdActorId,
   MAccountUrl
 } from '../account'
-import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting'
-import { AccountModel } from '@server/models/account/account'
 import { MChannelFormattable } from '../video/video-channels'
-import { MVideoPlaylist } from '@server/types/models'
+import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting'
 
 type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M>
 

+ 15 - 19
server/types/models/video/video-channels.ts

@@ -12,15 +12,17 @@ import {
   MAccountUserId,
   MActor,
   MActorAccountChannelId,
-  MActorAP,
+  MActorAPChannel,
   MActorAPI,
   MActorDefault,
+  MActorDefaultBanner,
   MActorDefaultLight,
   MActorFormattable,
   MActorHost,
   MActorLight,
   MActorSummary,
-  MActorSummaryFormattable, MActorUrl
+  MActorSummaryFormattable,
+  MActorUrl
 } from '../account'
 import { MVideo } from './video'
 
@@ -55,14 +57,14 @@ export type MChannelDefault =
   MChannel &
   Use<'Actor', MActorDefault>
 
+export type MChannelBannerDefault =
+  MChannel &
+  Use<'Actor', MActorDefaultBanner>
+
 // ############################################################################
 
 // Not all association attributes
 
-export type MChannelLight =
-  MChannel &
-  Use<'Actor', MActorDefaultLight>
-
 export type MChannelActorLight =
   MChannel &
   Use<'Actor', MActorLight>
@@ -84,29 +86,23 @@ export type MChannelAccountActor =
   MChannel &
   Use<'Account', MAccountActor>
 
-export type MChannelAccountDefault =
+export type MChannelBannerAccountDefault =
   MChannel &
-  Use<'Actor', MActorDefault> &
+  Use<'Actor', MActorDefaultBanner> &
   Use<'Account', MAccountDefault>
 
-export type MChannelActorAccountActor =
+export type MChannelAccountDefault =
   MChannel &
-  Use<'Account', MAccountActor> &
-  Use<'Actor', MActor>
+  Use<'Actor', MActorDefault> &
+  Use<'Account', MAccountDefault>
 
 // ############################################################################
 
-// Videos  associations
+// Videos associations
 export type MChannelVideos =
   MChannel &
   Use<'Videos', MVideo[]>
 
-export type MChannelActorAccountDefaultVideos =
-  MChannel &
-  Use<'Actor', MActorDefault> &
-  Use<'Account', MAccountDefault> &
-  Use<'Videos', MVideo[]>
-
 // ############################################################################
 
 // For API
@@ -146,5 +142,5 @@ export type MChannelFormattable =
 
 export type MChannelAP =
   Pick<MChannel, 'name' | 'description' | 'support'> &
-  Use<'Actor', MActorAP> &
+  Use<'Actor', MActorAPChannel> &
   Use<'Account', MAccountUrl>

+ 4 - 3
server/typings/express/index.d.ts

@@ -3,7 +3,10 @@ import {
   MAbuseMessage,
   MAbuseReporter,
   MAccountBlocklist,
+  MActorFollowActors,
+  MActorFollowActorsDefault,
   MActorUrl,
+  MChannelBannerAccountDefault,
   MStreamingPlaylist,
   MVideoChangeOwnershipFull,
   MVideoFile,
@@ -21,10 +24,8 @@ import { RegisteredPlugin } from '../../lib/plugins/plugin-manager'
 import {
   MAccountDefault,
   MActorAccountChannelId,
-  MActorFollowActorsDefault,
   MActorFollowActorsDefaultSubscription,
   MActorFull,
-  MChannelAccountDefault,
   MComment,
   MCommentOwnerVideoReply,
   MUserDefault,
@@ -71,7 +72,7 @@ interface PeerTubeLocals {
 
   videoStreamingPlaylist?: MStreamingPlaylist
 
-  videoChannel?: MChannelAccountDefault
+  videoChannel?: MChannelBannerAccountDefault
 
   videoPlaylistFull?: MVideoPlaylistFull
   videoPlaylistSummary?: MVideoPlaylistFullSummary

+ 2 - 1
shared/models/activitypub/activitypub-actor.ts

@@ -27,5 +27,6 @@ export interface ActivityPubActor {
     publicKeyPem: string
   }
 
-  icon: ActivityIconObject
+  icon?: ActivityIconObject
+  image?: ActivityIconObject
 }

+ 1 - 1
shared/models/activitypub/objects/common-objects.ts

@@ -9,7 +9,7 @@ export interface ActivityIdentifierObject {
 export interface ActivityIconObject {
   type: 'Image'
   url: string
-  mediaType: 'image/jpeg' | 'image/png'
+  mediaType: string
   width?: number
   height?: number
 }

+ 2 - 0
shared/models/videos/channel/video-channel.model.ts

@@ -15,6 +15,8 @@ export interface VideoChannel extends Actor {
 
   videosCount?: number
   viewsPerDay?: ViewsPerDate[] // chronologically ordered
+
+  banner?: ActorImage
 }
 
 export interface VideoChannelSummary {