From 2cb03dc1f4e01ba491c36caff30c33fe9c5bad89 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 6 Apr 2021 17:01:35 +0200 Subject: [PATCH] Add banners support --- server/controllers/api/config.ts | 4 +- server/controllers/api/users/me.ts | 8 +- .../controllers/api/users/my-subscriptions.ts | 12 +- server/controllers/api/video-channel.ts | 66 ++++++++--- server/controllers/api/videos/ownership.ts | 2 +- server/controllers/lazy-static.ts | 2 +- server/controllers/static.ts | 4 +- server/helpers/custom-validators/users.ts | 8 +- server/helpers/middlewares/video-channels.ts | 7 +- server/helpers/middlewares/videos.ts | 23 ++-- server/initializers/constants.ts | 20 +++- server/lib/activitypub/actor.ts | 106 +++++++++++++----- .../lib/activitypub/process/process-delete.ts | 6 +- .../lib/activitypub/process/process-update.ts | 14 ++- server/lib/actor-image.ts | 51 +++++---- server/lib/client-html.ts | 6 +- server/lib/emailer.ts | 2 +- server/lib/video-channel.ts | 16 +-- server/middlewares/validators/avatar.ts | 16 ++- server/middlewares/validators/follows.ts | 1 - .../validators/videos/video-channels.ts | 2 - server/models/activitypub/actor-follow.ts | 7 -- server/models/activitypub/actor.ts | 47 +++++++- server/models/video/video-channel.ts | 88 ++++++++------- server/types/models/account/account.ts | 10 +- server/types/models/account/actor-follow.ts | 11 +- server/types/models/account/actor.ts | 33 +++++- server/types/models/user/user.ts | 8 +- server/types/models/video/video-channels.ts | 34 +++--- server/typings/express/index.d.ts | 7 +- .../models/activitypub/activitypub-actor.ts | 3 +- .../activitypub/objects/common-objects.ts | 2 +- .../videos/channel/video-channel.model.ts | 2 + 33 files changed, 390 insertions(+), 238 deletions(-) diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index fb108ca1c..313513cea 100644 --- a/server/controllers/api/config.ts +++ b/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: { diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts index 4671ec5ac..25a18caa5 100644 --- a/server/controllers/api/users/me.ts +++ b/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) } diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts index ec77ddd7a..e8949ee59 100644 --- a/server/controllers/api/users/my-subscriptions.ts +++ b/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) { diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index c9d8e1120..1c926722d 100644 --- a/server/controllers/api/video-channel.ts +++ b/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) { diff --git a/server/controllers/api/videos/ownership.ts b/server/controllers/api/videos/ownership.ts index 86adb6c69..a85d7c30b 100644 --- a/server/controllers/api/videos/ownership.ts +++ b/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 diff --git a/server/controllers/lazy-static.ts b/server/controllers/lazy-static.ts index 68b5c9eec..6f71fdb16 100644 --- a/server/controllers/lazy-static.ts +++ b/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) diff --git a/server/controllers/static.ts b/server/controllers/static.ts index 4baa31117..e6a0628e6 100644 --- a/server/controllers/static.ts +++ b/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: { diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts index d6e91ad35..85f3634c8 100644 --- a/server/helpers/custom-validators/users.ts +++ b/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) } // --------------------------------------------------------------------------- diff --git a/server/helpers/middlewares/video-channels.ts b/server/helpers/middlewares/video-channels.ts index 05499bb74..e6eab65a2 100644 --- a/server/helpers/middlewares/video-channels.ts +++ b/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 } diff --git a/server/helpers/middlewares/videos.ts b/server/helpers/middlewares/videos.ts index c5eb0607a..403cae092 100644 --- a/server/helpers/middlewares/videos.ts +++ b/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 } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 3f934688b..1e74f3eab 100644 --- a/server/initializers/constants.ts +++ b/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, diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts index da831dcfd..fe4796a3d 100644 --- a/server/lib/activitypub/actor.ts +++ b/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 ({ @@ -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, diff --git a/server/lib/activitypub/process/process-delete.ts b/server/lib/activitypub/process/process-delete.ts index a86def936..070ee0f1d 100644 --- a/server/lib/activitypub/process/process-delete.ts +++ b/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) { 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 }) diff --git a/server/lib/actor-image.ts b/server/lib/actor-image.ts index ca7f9658d..59afa93bd 100644 --- a/server/lib/actor-image.ts +++ b/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((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({ max: LRU_CACHE. export { actorImagePathUnsafeCache, - updateLocalActorAvatarFile, - deleteLocalActorAvatarFile, + updateLocalActorImageFile, + deleteLocalActorImageFile, pushActorImageProcessInQueue } diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index fcc11c7b2..6ddaa82c8 100644 --- a/server/lib/client-html.ts +++ b/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' diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts index ce4134d59..9ca0d5d5b 100644 --- a/server/lib/emailer.ts +++ b/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', diff --git a/server/lib/video-channel.ts b/server/lib/video-channel.ts index 49bdf4869..0476cb2d5 100644 --- a/server/lib/video-channel.ts +++ b/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 = MChannelDefault & { Account?: T } - -async function createLocalVideoChannel ( - videoChannelInfo: VideoChannelCreate, - account: T, - t: Sequelize.Transaction -): Promise> { +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 ( const videoChannel = new VideoChannelModel(videoChannelData) const options = { transaction: t } - const videoChannelCreated: CustomVideoChannelModelAccount = 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 } diff --git a/server/middlewares/validators/avatar.ts b/server/middlewares/validators/avatar.ts index 2acb97483..f7eb367bd 100644 --- a/server/middlewares/validators/avatar.ts +++ b/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 } diff --git a/server/middlewares/validators/follows.ts b/server/middlewares/validators/follows.ts index a590aca99..bb849dc72 100644 --- a/server/middlewares/validators/follows.ts +++ b/server/middlewares/validators/follows.ts @@ -68,7 +68,6 @@ const removeFollowingValidator = [ .json({ error: `Following ${req.params.host} not found.` }) - .end() } res.locals.follow = follow diff --git a/server/middlewares/validators/videos/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts index 57ac548b9..2463d281c 100644 --- a/server/middlewares/validators/videos/video-channels.ts +++ b/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() diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts index ce6a4e267..4c5f37620 100644 --- a/server/models/activitypub/actor-follow.ts +++ b/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 { diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 09d96b24d..6595f11e2 100644 --- a/server/models/activitypub/actor.ts +++ b/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 diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index 815fb16c0..74885edfb 100644 --- a/server/models/video/video-channel.ts +++ b/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 { - return VideoChannelModel.unscoped() - .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) - .findByPk(id) - } - - static loadByIdAndAccount (id: number, accountId: number): Promise { - const query = { - where: { - id, - accountId - } - } - - return VideoChannelModel.unscoped() - .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) - .findOne(query) - } - - static loadAndPopulateAccount (id: number): Promise { + static loadAndPopulateAccount (id: number): Promise { 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 { + static loadByUrlAndPopulateAccount (url: string): Promise { 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 { + static loadLocalByNameAndPopulateAccount (name: string): Promise { 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 { + static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise { 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 { - 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() diff --git a/server/types/models/account/account.ts b/server/types/models/account/account.ts index d2add9810..9513acad8 100644 --- a/server/types/models/account/account.ts +++ b/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 = PickWith @@ -106,4 +106,4 @@ export type MAccountFormattable = export type MAccountAP = Pick & - Use<'Actor', MActorAP> + Use<'Actor', MActorAPAccount> diff --git a/server/types/models/account/actor-follow.ts b/server/types/models/account/actor-follow.ts index 8c213d09c..8e19c6140 100644 --- a/server/types/models/account/actor-follow.ts +++ b/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 = PickWith @@ -47,14 +46,10 @@ export type MActorFollowFull = // For subscriptions -type SubscriptionFollowing = - MActorDefault & - PickWith - export type MActorFollowActorsDefaultSubscription = MActorFollow & Use<'ActorFollower', MActorDefault> & - Use<'ActorFollowing', SubscriptionFollowing> + Use<'ActorFollowing', MActorDefaultChannelId> export type MActorFollowSubscriptions = MActorFollow & diff --git a/server/types/models/account/actor.ts b/server/types/models/account/actor.ts index 8af19c4da..8f3f30074 100644 --- a/server/types/models/account/actor.ts +++ b/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 = PickWith +type UseOpt = PickWithOpt // ############################################################################ @@ -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 & - Use<'Server', MServerHost & Partial>> + Pick & + Use<'Server', MServerHost & Partial>> & + UseOpt<'Banner', MActorImageFormattable> -export type MActorAP = +type MActorAPBase = MActor & Use<'Avatar', MActorImage> + +export type MActorAPAccount = + MActorAPBase + +export type MActorAPChannel = + MActorAPBase & + Use<'Banner', MActorImage> diff --git a/server/types/models/user/user.ts b/server/types/models/user/user.ts index 12a68accf..fa7de9c52 100644 --- a/server/types/models/user/user.ts +++ b/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 = PickWith diff --git a/server/types/models/video/video-channels.ts b/server/types/models/video/video-channels.ts index 77790daa4..f577807ca 100644 --- a/server/types/models/video/video-channels.ts +++ b/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 & - Use<'Actor', MActorAP> & + Use<'Actor', MActorAPChannel> & Use<'Account', MAccountUrl> diff --git a/server/typings/express/index.d.ts b/server/typings/express/index.d.ts index b0004dc7b..ee4faa35d 100644 --- a/server/typings/express/index.d.ts +++ b/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 diff --git a/shared/models/activitypub/activitypub-actor.ts b/shared/models/activitypub/activitypub-actor.ts index f022f3d02..c59be3f3b 100644 --- a/shared/models/activitypub/activitypub-actor.ts +++ b/shared/models/activitypub/activitypub-actor.ts @@ -27,5 +27,6 @@ export interface ActivityPubActor { publicKeyPem: string } - icon: ActivityIconObject + icon?: ActivityIconObject + image?: ActivityIconObject } diff --git a/shared/models/activitypub/objects/common-objects.ts b/shared/models/activitypub/objects/common-objects.ts index 76f0e3bcf..43d7f7f74 100644 --- a/shared/models/activitypub/objects/common-objects.ts +++ b/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 } diff --git a/shared/models/videos/channel/video-channel.model.ts b/shared/models/videos/channel/video-channel.model.ts index ae6dea42d..56517972d 100644 --- a/shared/models/videos/channel/video-channel.model.ts +++ b/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 { -- 2.41.0